tempest-react-sdk 0.76.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +2 -2
  2. package/bin/create-tempest-app.e2e.test.mjs +112 -0
  3. package/bin/create-tempest-app.mjs +69 -28
  4. package/bin/lib/alias/collect.mjs +20 -3
  5. package/bin/lib/alias/index.mjs +12 -1
  6. package/bin/lib/alias/index.test.mjs +24 -0
  7. package/bin/lib/component/generate.mjs +191 -0
  8. package/bin/lib/component/generate.test.mjs +336 -0
  9. package/bin/lib/css/analyze.test.mjs +21 -1
  10. package/bin/lib/css/collect.mjs +14 -0
  11. package/bin/lib/css/extract.mjs +63 -5
  12. package/bin/lib/css/extract.test.mjs +110 -1
  13. package/bin/lib/css/fix.e2e.test.mjs +50 -0
  14. package/bin/lib/css/index.mjs +6 -1
  15. package/bin/lib/fs/contain.mjs +50 -0
  16. package/bin/lib/icons/generate.mjs +28 -3
  17. package/bin/lib/icons/generate.test.mjs +57 -1
  18. package/bin/lib/openapi/escape.mjs +224 -0
  19. package/bin/lib/openapi/generate.encode.test.mjs +120 -0
  20. package/bin/lib/openapi/generate.hostile.test.mjs +621 -0
  21. package/bin/lib/openapi/generate.mjs +129 -18
  22. package/bin/lib/openapi/generate.test.mjs +7 -2
  23. package/bin/lib/openapi/schema-to-zod.mjs +43 -4
  24. package/bin/tempest-gen.test.mjs +188 -0
  25. package/bin/tempest.mjs +263 -6
  26. package/dist/auth/create-tempest-auth.cjs +1 -1
  27. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  28. package/dist/auth/create-tempest-auth.js +203 -58
  29. package/dist/auth/create-tempest-auth.js.map +1 -1
  30. package/dist/auth/refresh-queue.cjs +1 -1
  31. package/dist/auth/refresh-queue.cjs.map +1 -1
  32. package/dist/auth/refresh-queue.js +13 -6
  33. package/dist/auth/refresh-queue.js.map +1 -1
  34. package/dist/br/pix.cjs +1 -1
  35. package/dist/br/pix.cjs.map +1 -1
  36. package/dist/br/pix.js +85 -72
  37. package/dist/br/pix.js.map +1 -1
  38. package/dist/br.d.ts +19 -4
  39. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  40. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  41. package/dist/components/Chat/ChatAttachments.js +46 -38
  42. package/dist/components/Chat/ChatAttachments.js.map +1 -1
  43. package/dist/components/CodeBlock/CodeBlock.cjs +1 -1
  44. package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
  45. package/dist/components/CodeBlock/CodeBlock.js +1 -1
  46. package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
  47. package/dist/components/CodeBlock/tokenize.cjs +1 -1
  48. package/dist/components/CodeBlock/tokenize.cjs.map +1 -1
  49. package/dist/components/CodeBlock/tokenize.js +54 -25
  50. package/dist/components/CodeBlock/tokenize.js.map +1 -1
  51. package/dist/components/Markdown/markdown-parse.cjs +7 -5
  52. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  53. package/dist/components/Markdown/markdown-parse.js +171 -90
  54. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  55. package/dist/components/Toast/Toast.module.cjs +1 -1
  56. package/dist/components/Toast/Toast.module.cjs.map +1 -1
  57. package/dist/components/Toast/Toast.module.js +7 -3
  58. package/dist/components/Toast/Toast.module.js.map +1 -1
  59. package/dist/components/Toast/ToastProvider.cjs +1 -1
  60. package/dist/components/Toast/ToastProvider.cjs.map +1 -1
  61. package/dist/components/Toast/ToastProvider.js +77 -51
  62. package/dist/components/Toast/ToastProvider.js.map +1 -1
  63. package/dist/data/create-data-provider.cjs +1 -1
  64. package/dist/data/create-data-provider.cjs.map +1 -1
  65. package/dist/data/create-data-provider.js +32 -21
  66. package/dist/data/create-data-provider.js.map +1 -1
  67. package/dist/http/api-client.cjs +1 -1
  68. package/dist/http/api-client.cjs.map +1 -1
  69. package/dist/http/api-client.js +81 -73
  70. package/dist/http/api-client.js.map +1 -1
  71. package/dist/http/errors.cjs +1 -1
  72. package/dist/http/errors.cjs.map +1 -1
  73. package/dist/http/errors.js +39 -35
  74. package/dist/http/errors.js.map +1 -1
  75. package/dist/http/resumable-upload.cjs +1 -1
  76. package/dist/http/resumable-upload.cjs.map +1 -1
  77. package/dist/http/resumable-upload.js +141 -105
  78. package/dist/http/resumable-upload.js.map +1 -1
  79. package/dist/http/retry.cjs +1 -1
  80. package/dist/http/retry.cjs.map +1 -1
  81. package/dist/http/retry.js +5 -6
  82. package/dist/http/retry.js.map +1 -1
  83. package/dist/oauth/callback.cjs +2 -0
  84. package/dist/oauth/callback.cjs.map +1 -0
  85. package/dist/oauth/callback.js +42 -0
  86. package/dist/oauth/callback.js.map +1 -0
  87. package/dist/oauth/use-oauth-callback.cjs +1 -1
  88. package/dist/oauth/use-oauth-callback.cjs.map +1 -1
  89. package/dist/oauth/use-oauth-callback.js +13 -20
  90. package/dist/oauth/use-oauth-callback.js.map +1 -1
  91. package/dist/push/use-push-subscription.cjs +1 -1
  92. package/dist/push/use-push-subscription.cjs.map +1 -1
  93. package/dist/push/use-push-subscription.js +42 -42
  94. package/dist/push/use-push-subscription.js.map +1 -1
  95. package/dist/push/web-push-client.cjs +1 -1
  96. package/dist/push/web-push-client.cjs.map +1 -1
  97. package/dist/push/web-push-client.js +23 -3
  98. package/dist/push/web-push-client.js.map +1 -1
  99. package/dist/query/offline-persistence.cjs +1 -1
  100. package/dist/query/offline-persistence.cjs.map +1 -1
  101. package/dist/query/offline-persistence.js +68 -23
  102. package/dist/query/offline-persistence.js.map +1 -1
  103. package/dist/styles/component/Toast.css +6 -1
  104. package/dist/styles/feedback.css +6 -1
  105. package/dist/styles.css +1 -1
  106. package/dist/sw/background-sync.cjs +1 -1
  107. package/dist/sw/background-sync.cjs.map +1 -1
  108. package/dist/sw/background-sync.js +272 -60
  109. package/dist/sw/background-sync.js.map +1 -1
  110. package/dist/sw/cache-inspect.cjs.map +1 -1
  111. package/dist/sw/cache-inspect.js.map +1 -1
  112. package/dist/sw/cache.cjs +1 -1
  113. package/dist/sw/cache.cjs.map +1 -1
  114. package/dist/sw/cache.js +91 -64
  115. package/dist/sw/cache.js.map +1 -1
  116. package/dist/sw/credential-headers.cjs +2 -0
  117. package/dist/sw/credential-headers.cjs.map +1 -0
  118. package/dist/sw/credential-headers.js +17 -0
  119. package/dist/sw/credential-headers.js.map +1 -0
  120. package/dist/sw/session-data.cjs +2 -0
  121. package/dist/sw/session-data.cjs.map +1 -0
  122. package/dist/sw/session-data.js +17 -0
  123. package/dist/sw/session-data.js.map +1 -0
  124. package/dist/sw.cjs +1 -1
  125. package/dist/sw.d.ts +309 -23
  126. package/dist/sw.js +7 -5
  127. package/dist/telemetry/app-error-queue.cjs +1 -1
  128. package/dist/telemetry/app-error-queue.cjs.map +1 -1
  129. package/dist/telemetry/app-error-queue.js +19 -14
  130. package/dist/telemetry/app-error-queue.js.map +1 -1
  131. package/dist/telemetry/app-error-reporter.cjs +1 -1
  132. package/dist/telemetry/app-error-reporter.cjs.map +1 -1
  133. package/dist/telemetry/app-error-reporter.js +75 -34
  134. package/dist/telemetry/app-error-reporter.js.map +1 -1
  135. package/dist/telemetry/app-error-transport.cjs +1 -1
  136. package/dist/telemetry/app-error-transport.cjs.map +1 -1
  137. package/dist/telemetry/app-error-transport.js +16 -3
  138. package/dist/telemetry/app-error-transport.js.map +1 -1
  139. package/dist/tempest-react-sdk.cjs +1 -1
  140. package/dist/tempest-react-sdk.d.ts +1183 -64
  141. package/dist/tempest-react-sdk.js +160 -156
  142. package/dist/utils/csv.cjs +3 -3
  143. package/dist/utils/csv.cjs.map +1 -1
  144. package/dist/utils/csv.js +18 -14
  145. package/dist/utils/csv.js.map +1 -1
  146. package/dist/utils/owner-fingerprint.cjs +2 -0
  147. package/dist/utils/owner-fingerprint.cjs.map +1 -0
  148. package/dist/utils/owner-fingerprint.js +18 -0
  149. package/dist/utils/owner-fingerprint.js.map +1 -0
  150. package/dist/utils/safe-url.cjs +2 -0
  151. package/dist/utils/safe-url.cjs.map +1 -0
  152. package/dist/utils/safe-url.js +39 -0
  153. package/dist/utils/safe-url.js.map +1 -0
  154. package/dist/vite/tempest-csp.cjs +2 -2
  155. package/dist/vite/tempest-csp.cjs.map +1 -1
  156. package/dist/vite/tempest-csp.js +130 -64
  157. package/dist/vite/tempest-csp.js.map +1 -1
  158. package/dist/vite.d.ts +23 -5
  159. package/dist/webrtc/peer-link.cjs +1 -1
  160. package/dist/webrtc/peer-link.cjs.map +1 -1
  161. package/dist/webrtc/peer-link.js +2 -10
  162. package/dist/webrtc/peer-link.js.map +1 -1
  163. package/dist/webrtc/peer-mesh.cjs +1 -1
  164. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  165. package/dist/webrtc/peer-mesh.js +110 -72
  166. package/dist/webrtc/peer-mesh.js.map +1 -1
  167. package/package.json +1 -1
  168. package/template-pwa/src/pwa.tsx +22 -0
  169. package/template-pwa/src/sw.ts +27 -14
  170. package/dist/components/Markdown/markdown-url.cjs +0 -2
  171. package/dist/components/Markdown/markdown-url.cjs.map +0 -1
  172. package/dist/components/Markdown/markdown-url.js +0 -28
  173. package/dist/components/Markdown/markdown-url.js.map +0 -1
@@ -0,0 +1,621 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import vm from "node:vm";
5
+
6
+ import ts from "typescript";
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import { encodeIdSegment } from "../../../src/data";
10
+ import { generate } from "./generate.mjs";
11
+
12
+ const require = createRequire(import.meta.url);
13
+ /**
14
+ * Transpile one generated TypeScript file and run it in a fresh VM context.
15
+ *
16
+ * The context gets its own `globalThis`, so any `globalThis.PWNED_*` an injected
17
+ * statement sets lands on `context` and the test can read it back. `zod` is the
18
+ * real package, `tempest-react-sdk` exposes the real `encodeIdSegment` every path
19
+ * parameter goes through (#499); `./schemas` resolves to the module evaluated
20
+ * before it.
21
+ *
22
+ * @param {string} source - The generated `.ts` contents.
23
+ * @param {object} context - The VM context shared by the group's files.
24
+ * @param {Record<string, unknown>} modules - Already-evaluated sibling modules.
25
+ * @returns {Record<string, unknown>} The module's CommonJS exports.
26
+ */
27
+ function evaluate(source, context, modules) {
28
+ const { outputText, diagnostics } = ts.transpileModule(source, {
29
+ reportDiagnostics: true,
30
+ compilerOptions: {
31
+ module: ts.ModuleKind.CommonJS,
32
+ target: ts.ScriptTarget.ES2022,
33
+ },
34
+ });
35
+ expect(diagnostics ?? []).toEqual([]);
36
+ const module = { exports: {} };
37
+ const localRequire = (id) => {
38
+ if (id in modules) return modules[id];
39
+ if (id === "zod") return require("zod");
40
+ if (id === "tempest-react-sdk") return { encodeIdSegment };
41
+ throw new Error(`unexpected import ${id}`);
42
+ };
43
+ vm.runInContext(`(function (exports, require, module) {\n${outputText}\n})`, context)(
44
+ module.exports,
45
+ localRequire,
46
+ module,
47
+ );
48
+ return module.exports;
49
+ }
50
+ /**
51
+ * Evaluate a generated group (`schemas.ts` then `service.ts`) and call every
52
+ * method of its service class with a recording fake `ApiClient`.
53
+ *
54
+ * @param {Record<string, string>} files - The generator output.
55
+ * @param {string} slug - The group folder.
56
+ * @returns {Promise<{ context: object, urls: string[] }>}
57
+ */
58
+ async function runGroup(files, slug) {
59
+ const context = vm.createContext({});
60
+ const schemas = evaluate(files[`${slug}/schemas.ts`], context, {});
61
+ const service = evaluate(files[`${slug}/service.ts`], context, { "./schemas": schemas });
62
+ const urls = [];
63
+ const api = new Proxy(
64
+ {},
65
+ {
66
+ get: () => async (url) => {
67
+ urls.push(url);
68
+ return undefined;
69
+ },
70
+ },
71
+ );
72
+ const Class = Object.values(service).find((v) => typeof v === "function");
73
+ const instance = new Class(api);
74
+ const proto = Object.getPrototypeOf(instance);
75
+ for (const name of Object.getOwnPropertyNames(proto)) {
76
+ if (name === "constructor") continue;
77
+ await instance[name]("v-value", {});
78
+ }
79
+ return { context, urls };
80
+ }
81
+ /** Wrap one operation into a minimal OpenAPI document. */
82
+ function spec(paths, schemas = {}) {
83
+ return { openapi: "3.1.0", components: { schemas }, paths };
84
+ }
85
+
86
+ const OK = { 200: { description: "ok" } };
87
+
88
+ describe("generate — hostile spec text never becomes code (#434)", () => {
89
+ it("a tag that closes the class doc comment stays inside it", async () => {
90
+ const tag = 'items" routes. */ globalThis.PWNED_TAG = 1; /* x';
91
+ const { files } = generate(spec({ "/a": { get: { tags: [tag], responses: OK } } }));
92
+ const slug = Object.keys(files)
93
+ .find((f) => f.endsWith("/service.ts"))
94
+ .split("/")[0];
95
+ const { context } = await runGroup(files, slug);
96
+ expect(context.PWNED_TAG).toBeUndefined();
97
+ });
98
+
99
+ it("a summary that closes the method doc comment stays inside it", async () => {
100
+ const summary = "*/ static { globalThis.PWNED_SUMMARY = 1 } /**";
101
+ const { files } = generate(
102
+ spec({ "/a": { get: { tags: ["t"], summary, responses: OK } } }),
103
+ );
104
+ const { context } = await runGroup(files, "t");
105
+ expect(context.PWNED_SUMMARY).toBeUndefined();
106
+ });
107
+
108
+ it("a summary with a line break cannot start a new statement", async () => {
109
+ const summary = "first\n*/ globalThis.PWNED_NEWLINE = 1; /*";
110
+ const { files } = generate(
111
+ spec({ "/a": { get: { tags: ["t"], summary, responses: OK } } }),
112
+ );
113
+ expect(files["t/service.ts"]).not.toMatch(/first\n/);
114
+ const { context } = await runGroup(files, "t");
115
+ expect(context.PWNED_NEWLINE).toBeUndefined();
116
+ });
117
+
118
+ it("a path that breaks out of the URL template literal is sent verbatim", async () => {
119
+ const path = "/run/`+(globalThis.PWNED_TICK=1)+`/\\/$/{v}/${tail";
120
+ const { files } = generate(
121
+ spec({
122
+ [path]: {
123
+ get: {
124
+ tags: ["t"],
125
+ parameters: [{ name: "v", in: "path", required: true }],
126
+ responses: OK,
127
+ },
128
+ },
129
+ }),
130
+ );
131
+ const { context, urls } = await runGroup(files, "t");
132
+ expect(context.PWNED_TICK).toBeUndefined();
133
+ expect(urls).toEqual(["/run/`+(globalThis.PWNED_TICK=1)+`/\\/$/v-value/${tail"]);
134
+ });
135
+
136
+ it("a path placeholder that is not a declared path parameter is refused", () => {
137
+ expect(() =>
138
+ generate(
139
+ spec({
140
+ "/run/{globalThis.PWNED_PATH=1}": {
141
+ get: {
142
+ tags: ["t"],
143
+ parameters: [{ name: "v", in: "path", required: true }],
144
+ responses: OK,
145
+ },
146
+ },
147
+ }),
148
+ ),
149
+ ).toThrow(/placeholder "globalThis.PWNED_PATH=1".*not a declared path parameter/);
150
+ });
151
+
152
+ it("a path parameter whose name injects code is mapped to an inert identifier", async () => {
153
+ const name = "a=globalThis.PWNED_PARAM=1";
154
+ const { files } = generate(
155
+ spec({
156
+ [`/run/{${name}}`]: {
157
+ get: {
158
+ tags: ["t"],
159
+ parameters: [{ name, in: "path", required: true }],
160
+ responses: OK,
161
+ },
162
+ },
163
+ }),
164
+ );
165
+ expect(
166
+ files["t/service.ts"],
167
+ "was refused until #434's follow-up: refusing every non-identifier also refused " +
168
+ "real `{user-id}` params; the mapping keeps only [A-Za-z0-9]",
169
+ ).toContain("aGlobalThisPWNEDPARAM1: string");
170
+ const { context, urls } = await runGroup(files, "t");
171
+ expect(context.PWNED_PARAM).toBeUndefined();
172
+ expect(urls).toEqual(["/run/v-value"]);
173
+ });
174
+
175
+ it("a schema name that injects a statement is mapped to an inert identifier", async () => {
176
+ const name = "Thing = z.string(); globalThis.PWNED_NAME = 1; export const Other";
177
+ const { files } = generate(
178
+ spec(
179
+ {
180
+ "/a": {
181
+ post: {
182
+ tags: ["t"],
183
+ requestBody: {
184
+ content: {
185
+ "application/json": {
186
+ schema: { $ref: `#/components/schemas/${name}` },
187
+ },
188
+ },
189
+ },
190
+ responses: OK,
191
+ },
192
+ },
193
+ },
194
+ { [name]: { type: "object" } },
195
+ ),
196
+ );
197
+ expect(
198
+ files["t/schemas.ts"],
199
+ "was refused until #434's follow-up: `user.Response` / `User-Out` are legitimate " +
200
+ "names, and the PascalCase mapping leaves nothing executable",
201
+ ).toContain("export const ThingZStringGlobalThisPWNEDNAME1ExportConstOtherSchema =");
202
+ const context = vm.createContext({});
203
+ const S = evaluate(files["t/schemas.ts"], context, {});
204
+ evaluate(files["t/service.ts"], context, { "./schemas": S });
205
+ expect(context.PWNED_NAME).toBeUndefined();
206
+ });
207
+
208
+ it("a schema named after a reserved word gets a _ prefix", async () => {
209
+ const { files } = generate(
210
+ spec(
211
+ {
212
+ "/a": {
213
+ get: {
214
+ tags: ["t"],
215
+ responses: {
216
+ 200: {
217
+ content: {
218
+ "application/json": {
219
+ schema: { $ref: "#/components/schemas/string" },
220
+ },
221
+ },
222
+ },
223
+ },
224
+ },
225
+ },
226
+ },
227
+ { string: { type: "object" } },
228
+ ),
229
+ );
230
+ expect(files["t/types.ts"]).toContain(
231
+ "export type _string = z.infer<typeof S._stringSchema>;",
232
+ );
233
+ await runGroup(files, "t");
234
+ });
235
+
236
+ it("two schema names that map to the same identifier are refused, naming both", () => {
237
+ expect(() =>
238
+ generate(spec({}, { "User-Out": { type: "object" }, "user.Out": { type: "object" } })),
239
+ ).toThrow(/"User-Out" and "user\.Out" both map to the TypeScript identifier "UserOut"/);
240
+ });
241
+
242
+ it("a path parameter that maps onto another method argument is refused", () => {
243
+ expect(() =>
244
+ generate(
245
+ spec({
246
+ "/a/{user-id}/{userId}": {
247
+ get: {
248
+ tags: ["t"],
249
+ parameters: [
250
+ { name: "user-id", in: "path" },
251
+ { name: "userId", in: "path" },
252
+ ],
253
+ responses: OK,
254
+ },
255
+ },
256
+ }),
257
+ ),
258
+ ).toThrow(/"user-id" and "userId" both map to the TypeScript identifier "userId"/);
259
+ });
260
+
261
+ it("a name with nothing to build an identifier from is refused", () => {
262
+ expect(() => generate(spec({}, { "***": { type: "object" } }))).toThrow(
263
+ /schema name "\*\*\*" has no letter or digit/,
264
+ );
265
+ });
266
+
267
+ it("a regex pattern with an escaped slash or a line break compiles to the same regex", () => {
268
+ const schemas = {
269
+ Body: {
270
+ type: "object",
271
+ properties: {
272
+ slash: { type: "string", pattern: "^a\\/b$" },
273
+ newline: { type: "string", pattern: "^a\nb$" },
274
+ },
275
+ },
276
+ };
277
+ const { files } = generate(
278
+ spec(
279
+ {
280
+ "/a": {
281
+ post: {
282
+ tags: ["t"],
283
+ requestBody: {
284
+ content: {
285
+ "application/json": {
286
+ schema: { $ref: "#/components/schemas/Body" },
287
+ },
288
+ },
289
+ },
290
+ responses: OK,
291
+ },
292
+ },
293
+ },
294
+ schemas,
295
+ ),
296
+ );
297
+ const S = evaluate(files["t/schemas.ts"], vm.createContext({}), {});
298
+ expect(S.BodySchema.safeParse({ slash: "a/b", newline: "a\nb" }).success).toBe(true);
299
+ expect(S.BodySchema.safeParse({ slash: "a\\/b" }).success).toBe(false);
300
+ });
301
+
302
+ it("an operationId that sanitizes to nothing still yields a valid method", async () => {
303
+ const { files } = generate(
304
+ spec({
305
+ "/a/{v}": {
306
+ get: {
307
+ tags: ["t"],
308
+ operationId: "123",
309
+ parameters: [{ name: "v", in: "path" }],
310
+ responses: OK,
311
+ },
312
+ },
313
+ }),
314
+ );
315
+ const { urls } = await runGroup(files, "t");
316
+ expect(urls).toEqual(["/a/v-value"]);
317
+ });
318
+
319
+ it("a tag that slugs to a leading digit still yields a valid class", async () => {
320
+ const { files } = generate(spec({ "/a": { get: { tags: ["2fa"], responses: OK } } }));
321
+ const { urls } = await runGroup(files, "2fa");
322
+ expect(urls).toEqual(["/a"]);
323
+ });
324
+ });
325
+
326
+ describe("generate — real-world names that are not identifiers are mapped", () => {
327
+ const REAL = spec(
328
+ {
329
+ "/users/{user-id}/profile": {
330
+ put: {
331
+ tags: ["users"],
332
+ operationId: "update_profile",
333
+ parameters: [
334
+ { name: "user-id", in: "path", required: true, schema: { type: "string" } },
335
+ ],
336
+ requestBody: {
337
+ content: {
338
+ "application/json": {
339
+ schema: { $ref: "#/components/schemas/User-In" },
340
+ },
341
+ },
342
+ },
343
+ responses: {
344
+ 200: {
345
+ content: {
346
+ "application/json": {
347
+ schema: { $ref: "#/components/schemas/user.Response" },
348
+ },
349
+ },
350
+ },
351
+ },
352
+ },
353
+ },
354
+ },
355
+ {
356
+ "User-In": {
357
+ type: "object",
358
+ required: ["display-name"],
359
+ properties: { "display-name": { type: "string" } },
360
+ },
361
+ "user.Response": {
362
+ type: "object",
363
+ properties: {
364
+ id: { type: "string" },
365
+ owner: { $ref: "#/components/schemas/User-In" },
366
+ },
367
+ },
368
+ },
369
+ );
370
+
371
+ it("generates code that transpiles, runs and builds the URL with the spec's names", async () => {
372
+ const { files } = generate(REAL);
373
+ const service = files["users/service.ts"];
374
+ expect(service).toContain(
375
+ "async updateProfile(userId: string, body: UserIn): Promise<UserResponse>",
376
+ );
377
+ expect(service).toContain("S.UserInSchema.parse(body);");
378
+ expect(files["users/types.ts"]).toContain(
379
+ "export type UserResponse = z.infer<typeof S.UserResponseSchema>;",
380
+ );
381
+ expect(files["users/schemas.ts"]).toContain('"display-name": z.string()');
382
+
383
+ const context = vm.createContext({});
384
+ const S = evaluate(files["users/schemas.ts"], context, {});
385
+ const mod = evaluate(service, context, { "./schemas": S });
386
+ const calls = [];
387
+ const api = { put: async (url, opts) => calls.push({ url, opts }) };
388
+ await new mod.UsersService(api).updateProfile("42", { "display-name": "Ana" });
389
+ expect(calls).toEqual([
390
+ { url: "/users/42/profile", opts: { body: { "display-name": "Ana" } } },
391
+ ]);
392
+ await expect(new mod.UsersService(api).updateProfile("42", {})).rejects.toThrow();
393
+ });
394
+ });
395
+
396
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
397
+
398
+ /**
399
+ * Type-check a generated group with the full TypeScript checker, not just the
400
+ * parser: duplicate parameters, shadowed imports and non-generic `Promise` are
401
+ * semantic errors that `transpileModule` does not report.
402
+ *
403
+ * Files live in a virtual folder under the repository so `zod` resolves from its
404
+ * `node_modules`; `tempest-react-sdk` is swapped for a stub with the three types
405
+ * and the `encodeIdSegment` value the generator imports from it.
406
+ *
407
+ * @param {Record<string, string>} files - The generator output.
408
+ * @param {string} slug - The group folder.
409
+ * @returns {string[]} Diagnostic messages.
410
+ */
411
+ function typecheck(files, slug) {
412
+ const root = join(REPO, "__virtual_openapi__");
413
+ const virtual = new Map(
414
+ ["schemas.ts", "types.ts", "service.ts"].map((f) => [
415
+ join(root, f),
416
+ files[`${slug}/${f}`].replaceAll('from "tempest-react-sdk"', 'from "./sdk"'),
417
+ ]),
418
+ );
419
+ virtual.set(
420
+ join(root, "sdk.ts"),
421
+ [
422
+ "type Call = <T = unknown>(url: string, opts?: unknown) => Promise<T>;",
423
+ "export type ApiClient = { get: Call; post: Call; put: Call; patch: Call; delete: Call };",
424
+ "export type OffsetPage<T> = { items: T[]; total: number };",
425
+ "export type CursorPage<T> = { items: T[]; next_cursor: string | null };",
426
+ "export declare function encodeIdSegment(id: string | number): string;",
427
+ ].join("\n"),
428
+ );
429
+ const options = {
430
+ strict: true,
431
+ target: ts.ScriptTarget.ES2022,
432
+ module: ts.ModuleKind.ESNext,
433
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
434
+ skipLibCheck: true,
435
+ noEmit: true,
436
+ };
437
+ const host = ts.createCompilerHost(options);
438
+ const readFile = host.readFile.bind(host);
439
+ const fileExists = host.fileExists.bind(host);
440
+ const directoryExists = host.directoryExists?.bind(host);
441
+ const getSourceFile = host.getSourceFile.bind(host);
442
+ host.readFile = (f) => virtual.get(f) ?? readFile(f);
443
+ host.fileExists = (f) => virtual.has(f) || fileExists(f);
444
+ host.directoryExists = (d) => d === root || (directoryExists?.(d) ?? true);
445
+ host.getSourceFile = (f, lang) =>
446
+ virtual.has(f) ? ts.createSourceFile(f, virtual.get(f), lang) : getSourceFile(f, lang);
447
+ const program = ts.createProgram([...virtual.keys()], options, host);
448
+ return ts
449
+ .getPreEmitDiagnostics(program)
450
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
451
+ }
452
+
453
+ const JSON_BODY = (ref) => ({
454
+ content: { "application/json": { schema: { $ref: `#/components/schemas/${ref}` } } },
455
+ });
456
+ const JSON_OK = (ref) => ({ 200: JSON_BODY(ref) });
457
+
458
+ describe("generate — names that collide with what the generator itself declares", () => {
459
+ it("a path parameter named body, params, S or encodeIdSegment is renamed, not duplicated or shadowing", async () => {
460
+ const { files } = generate(
461
+ spec(
462
+ {
463
+ "/a/{body}/{params}/{S}/{encodeIdSegment}": {
464
+ post: {
465
+ tags: ["t"],
466
+ operationId: "send",
467
+ parameters: [
468
+ { name: "body", in: "path", required: true },
469
+ { name: "params", in: "path", required: true },
470
+ { name: "S", in: "path", required: true },
471
+ { name: "encodeIdSegment", in: "path", required: true },
472
+ { name: "q", in: "query" },
473
+ ],
474
+ requestBody: JSON_BODY("Req"),
475
+ responses: OK,
476
+ },
477
+ },
478
+ },
479
+ { Req: { type: "object" } },
480
+ ),
481
+ );
482
+ expect(typecheck(files, "t")).toEqual([]);
483
+ const context = vm.createContext({});
484
+ const S = evaluate(files["t/schemas.ts"], context, {});
485
+ const mod = evaluate(files["t/service.ts"], context, { "./schemas": S });
486
+ const calls = [];
487
+ const api = { post: async (url, opts) => calls.push({ url, opts }) };
488
+ await new mod.TService(api).send("b", "p", "s", "e/x", { k: 1 }, { q: "x" });
489
+ expect(files["t/service.ts"]).toContain("${encodeIdSegment(encodeIdSegmentParam)}");
490
+ expect(calls).toEqual([
491
+ { url: "/a/b/p/s/e%2Fx", opts: { body: { k: 1 }, params: { q: "x" } } },
492
+ ]);
493
+ });
494
+
495
+ it("an operationId of constructor or api becomes a distinct method", async () => {
496
+ const { files } = generate(
497
+ spec({
498
+ "/a": { get: { tags: ["t"], operationId: "constructor", responses: OK } },
499
+ "/b": { get: { tags: ["t"], operationId: "api", responses: OK } },
500
+ }),
501
+ );
502
+ expect(typecheck(files, "t")).toEqual([]);
503
+ const { urls } = await runGroup(files, "t");
504
+ expect(urls.sort()).toEqual(["/a", "/b"]);
505
+ });
506
+
507
+ it("path parameters named eval or arguments are prefixed for strict mode", async () => {
508
+ const { files } = generate(
509
+ spec({
510
+ "/a/{eval}/{arguments}": {
511
+ get: {
512
+ tags: ["t"],
513
+ operationId: "get",
514
+ parameters: [
515
+ { name: "eval", in: "path" },
516
+ { name: "arguments", in: "path" },
517
+ ],
518
+ responses: OK,
519
+ },
520
+ },
521
+ }),
522
+ );
523
+ expect(typecheck(files, "t")).toEqual([]);
524
+ expect(files["t/service.ts"]).toContain("_eval: string, _arguments: string");
525
+ const context = vm.createContext({});
526
+ const mod = evaluate(files["t/service.ts"], context, {});
527
+ const calls = [];
528
+ await new mod.TService({ get: async (url) => calls.push(url) }).get("e", "a");
529
+ expect(calls).toEqual(["/a/e/a"]);
530
+ });
531
+
532
+ it("two tags whose barrel names collide are refused, naming both", () => {
533
+ expect(() =>
534
+ generate(
535
+ spec({
536
+ "/a": { get: { tags: ["user 1"], responses: OK } },
537
+ "/b": { get: { tags: ["user1"], responses: OK } },
538
+ }),
539
+ ),
540
+ ).toThrow(/"user-1" and "user1" both map to the TypeScript identifier "user1"/);
541
+ });
542
+
543
+ it("schemas named after generated imports or used globals are prefixed", () => {
544
+ const page = {
545
+ type: "object",
546
+ properties: {
547
+ items: { type: "array", items: { $ref: "#/components/schemas/Promise" } },
548
+ total: { type: "integer" },
549
+ pages: { type: "integer" },
550
+ size: { type: "integer" },
551
+ },
552
+ };
553
+ const names = [
554
+ "ApiClient",
555
+ "OffsetPage",
556
+ "CursorPage",
557
+ "Promise",
558
+ "Record",
559
+ "z",
560
+ "S",
561
+ "encodeIdSegment",
562
+ ];
563
+ const paths = Object.fromEntries(
564
+ names.map((n, i) => [
565
+ `/n${i}`,
566
+ { post: { tags: ["t"], requestBody: JSON_BODY(n), responses: JSON_OK("Page") } },
567
+ ]),
568
+ );
569
+ paths["/free"] = {
570
+ get: {
571
+ tags: ["t"],
572
+ responses: {
573
+ 200: { content: { "application/json": { schema: { type: "object" } } } },
574
+ },
575
+ },
576
+ };
577
+ paths["/p/{id}"] = {
578
+ get: {
579
+ tags: ["t"],
580
+ parameters: [{ name: "id", in: "path", required: true }],
581
+ responses: OK,
582
+ },
583
+ };
584
+ const schemas = Object.fromEntries(names.map((n) => [n, { type: "object" }]));
585
+ schemas.Page = page;
586
+ const { files } = generate(spec(paths, schemas));
587
+ expect(files["t/service.ts"]).toContain("OffsetPage<_Promise>");
588
+ expect(files["t/service.ts"]).toContain(
589
+ 'import { encodeIdSegment } from "tempest-react-sdk";',
590
+ );
591
+ expect(files["t/types.ts"]).toContain("export type _encodeIdSegment =");
592
+ expect(typecheck(files, "t")).toEqual([]);
593
+ });
594
+
595
+ it("an invalid regex pattern fails generation, naming the schema and property", () => {
596
+ expect(() =>
597
+ generate(
598
+ spec(
599
+ {
600
+ "/a": {
601
+ post: { tags: ["t"], requestBody: JSON_BODY("Body"), responses: OK },
602
+ },
603
+ },
604
+ {
605
+ Body: {
606
+ type: "object",
607
+ properties: {
608
+ inner: {
609
+ type: "object",
610
+ properties: { code: { type: "string", pattern: "(" } },
611
+ },
612
+ },
613
+ },
614
+ },
615
+ ),
616
+ ),
617
+ ).toThrow(
618
+ /schema "Body" at properties\.inner\.properties\.code: pattern "\(" is not a valid/,
619
+ );
620
+ });
621
+ });