swallowkit 1.1.1 → 2.0.0-alpha.1

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 (93) hide show
  1. package/README.ja.md +47 -7
  2. package/README.md +47 -7
  3. package/dist/cli/commands/add-auth.d.ts.map +1 -1
  4. package/dist/cli/commands/add-auth.js +66 -21
  5. package/dist/cli/commands/add-auth.js.map +1 -1
  6. package/dist/cli/commands/dev.d.ts +4 -1
  7. package/dist/cli/commands/dev.d.ts.map +1 -1
  8. package/dist/cli/commands/dev.js +108 -44
  9. package/dist/cli/commands/dev.js.map +1 -1
  10. package/dist/cli/commands/init.d.ts +6 -5
  11. package/dist/cli/commands/init.d.ts.map +1 -1
  12. package/dist/cli/commands/init.js +83 -39
  13. package/dist/cli/commands/init.js.map +1 -1
  14. package/dist/cli/commands/scaffold.d.ts +1 -1
  15. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  16. package/dist/cli/commands/scaffold.js +44 -16
  17. package/dist/cli/commands/scaffold.js.map +1 -1
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +6 -3
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/core/config.d.ts +2 -1
  22. package/dist/core/config.d.ts.map +1 -1
  23. package/dist/core/config.js +19 -0
  24. package/dist/core/config.js.map +1 -1
  25. package/dist/core/operations/migration-generator.d.ts +1 -1
  26. package/dist/core/operations/migration-generator.d.ts.map +1 -1
  27. package/dist/core/operations/migration-generator.js +22 -0
  28. package/dist/core/operations/migration-generator.js.map +1 -1
  29. package/dist/core/project/agent-instructions.d.ts +2 -1
  30. package/dist/core/project/agent-instructions.d.ts.map +1 -1
  31. package/dist/core/project/agent-instructions.js +12 -0
  32. package/dist/core/project/agent-instructions.js.map +1 -1
  33. package/dist/core/project/capabilities.d.ts +7 -0
  34. package/dist/core/project/capabilities.d.ts.map +1 -1
  35. package/dist/core/project/capabilities.js +7 -0
  36. package/dist/core/project/capabilities.js.map +1 -1
  37. package/dist/core/project/manifest.d.ts +3 -1
  38. package/dist/core/project/manifest.d.ts.map +1 -1
  39. package/dist/core/project/manifest.js +47 -25
  40. package/dist/core/project/manifest.js.map +1 -1
  41. package/dist/core/project/react-project.d.ts +26 -0
  42. package/dist/core/project/react-project.d.ts.map +1 -0
  43. package/dist/core/project/react-project.js +422 -0
  44. package/dist/core/project/react-project.js.map +1 -0
  45. package/dist/core/project/validation.d.ts.map +1 -1
  46. package/dist/core/project/validation.js +32 -10
  47. package/dist/core/project/validation.js.map +1 -1
  48. package/dist/core/scaffold/react-bff-generator.d.ts +10 -0
  49. package/dist/core/scaffold/react-bff-generator.d.ts.map +1 -0
  50. package/dist/core/scaffold/react-bff-generator.js +252 -0
  51. package/dist/core/scaffold/react-bff-generator.js.map +1 -0
  52. package/dist/core/scaffold/react-ui-generator.d.ts +6 -0
  53. package/dist/core/scaffold/react-ui-generator.d.ts.map +1 -0
  54. package/dist/core/scaffold/react-ui-generator.js +41 -0
  55. package/dist/core/scaffold/react-ui-generator.js.map +1 -0
  56. package/dist/index.d.ts +3 -3
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -2
  59. package/dist/index.js.map +1 -1
  60. package/dist/machine/index.js +2 -2
  61. package/dist/machine/index.js.map +1 -1
  62. package/dist/types/index.d.ts +4 -0
  63. package/dist/types/index.d.ts.map +1 -1
  64. package/dist/utils/package-manager.d.ts +2 -2
  65. package/dist/utils/package-manager.d.ts.map +1 -1
  66. package/dist/utils/package-manager.js +4 -1
  67. package/dist/utils/package-manager.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/__tests__/agent-instructions.test.ts +1 -1
  70. package/src/__tests__/init.test.ts +29 -5
  71. package/src/__tests__/plan-apply.test.ts +101 -0
  72. package/src/__tests__/react.test.ts +819 -0
  73. package/src/__tests__/release-metadata.test.ts +121 -0
  74. package/src/cli/commands/add-auth.ts +67 -23
  75. package/src/cli/commands/dev.ts +109 -46
  76. package/src/cli/commands/init.ts +75 -35
  77. package/src/cli/commands/scaffold.ts +45 -20
  78. package/src/cli/index.ts +6 -3
  79. package/src/core/config.ts +21 -1
  80. package/src/core/operations/migration-generator.ts +35 -1
  81. package/src/core/project/agent-instructions.ts +14 -1
  82. package/src/core/project/agent-templates/AGENTS.md +24 -9
  83. package/src/core/project/agent-templates/bff-routes.instructions.md +5 -3
  84. package/src/core/project/capabilities.ts +14 -0
  85. package/src/core/project/manifest.ts +52 -27
  86. package/src/core/project/react-project.ts +507 -0
  87. package/src/core/project/validation.ts +32 -10
  88. package/src/core/scaffold/react-bff-generator.ts +292 -0
  89. package/src/core/scaffold/react-ui-generator.ts +51 -0
  90. package/src/index.ts +3 -1
  91. package/src/machine/index.ts +2 -2
  92. package/src/types/index.ts +4 -0
  93. package/src/utils/package-manager.ts +5 -2
@@ -0,0 +1,819 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as net from "net";
4
+ import ts from "typescript";
5
+ import type { Mock } from "jest-mock";
6
+ import { z } from "zod/v4";
7
+ import {
8
+ getFrontendFramework,
9
+ loadConfig,
10
+ validateConfig,
11
+ } from "../core/config";
12
+ import {
13
+ createReactProject,
14
+ createReactBffProject,
15
+ buildReactSwaConfig,
16
+ } from "../core/project/react-project";
17
+ import {
18
+ generateManagedFunctionConfig,
19
+ generateReactBffHelper,
20
+ generateReactBffRoute,
21
+ generateReactAuthBff,
22
+ } from "../core/scaffold/react-bff-generator";
23
+ import { createBasicModelInfo, createRdbConnectorModelInfo } from "./fixtures";
24
+ import { planScaffoldOperation } from "../core/operations/scaffold-plan";
25
+ import { applyScaffoldOperation } from "../core/operations/scaffold-apply";
26
+ import { buildProjectManifest } from "../core/project/manifest";
27
+ import { validateProject } from "../core/project/validation";
28
+ import {
29
+ buildAgentsMd,
30
+ buildPathSpecificInstructions,
31
+ } from "../core/project/agent-instructions";
32
+ import {
33
+ buildGitHubSwaWorkflow,
34
+ buildAzureSwaPipeline,
35
+ buildSwallowKitConfigSource,
36
+ } from "../cli/commands/init";
37
+ import { getBuildScript } from "../utils/package-manager";
38
+ import {
39
+ assertDevPortAvailable,
40
+ buildReactDevArgs,
41
+ buildSwaStartArgs,
42
+ } from "../cli/commands/dev";
43
+ import { addAuthCommand } from "../cli/commands/add-auth";
44
+ import { captureConsoleMessages } from "../core/operations/runtime";
45
+
46
+ interface TestRequest {
47
+ method: string;
48
+ headers: Record<string, string>;
49
+ params: Record<string, string>;
50
+ query?: Record<string, string>;
51
+ body?: unknown;
52
+ }
53
+ interface TestResponse {
54
+ status: number;
55
+ body?: unknown;
56
+ headers?: Record<string, string>;
57
+ }
58
+ interface Helper {
59
+ callFunction(config: {
60
+ request: TestRequest;
61
+ method: string;
62
+ path: string;
63
+ body?: unknown;
64
+ inputSchema?: z.ZodType;
65
+ responseSchema?: z.ZodType;
66
+ }): Promise<TestResponse>;
67
+ }
68
+ type Handler = (
69
+ _context: object,
70
+ request: TestRequest
71
+ ) => Promise<TestResponse>;
72
+
73
+ function evaluate<T>(source: string, modules: Record<string, unknown> = {}): T {
74
+ const { outputText, diagnostics } = ts.transpileModule(source, {
75
+ compilerOptions: {
76
+ module: ts.ModuleKind.CommonJS,
77
+ target: ts.ScriptTarget.ES2022,
78
+ esModuleInterop: true,
79
+ },
80
+ reportDiagnostics: true,
81
+ });
82
+ expect(diagnostics).toEqual([]);
83
+ const output = { exports: {} };
84
+ new Function("require", "module", "exports", outputText)(
85
+ (name: string) => {
86
+ if (name === "zod/v4") return { z };
87
+ if (name in modules) return modules[name];
88
+ throw new Error(`Unexpected generated runtime import: ${name}`);
89
+ },
90
+ output,
91
+ output.exports
92
+ );
93
+ return output.exports as T;
94
+ }
95
+
96
+ function request(overrides: Partial<TestRequest> = {}): TestRequest {
97
+ return { method: "GET", headers: {}, params: {}, ...overrides };
98
+ }
99
+
100
+ describe("React managed BFF runtime", () => {
101
+ const originalEnv = { ...process.env };
102
+ let fetchMock: Mock<typeof fetch>;
103
+ function fetchInit(index = 0) {
104
+ const init = fetchMock.mock.calls[index][1];
105
+ const headers: Record<string, string> = {};
106
+ new Headers(init?.headers).forEach((value, key) => {
107
+ headers[key] = value;
108
+ });
109
+ return { ...init, headers };
110
+ }
111
+ beforeEach(() => {
112
+ process.env.BACKEND_FUNCTIONS_BASE_URL = "https://backend.example.test";
113
+ process.env.BACKEND_FUNCTIONS_KEY = "server-only-key";
114
+ fetchMock = jest.fn<typeof fetch>(
115
+ async () =>
116
+ new Response(JSON.stringify({ id: "one", title: "Hello" }), {
117
+ status: 200,
118
+ })
119
+ );
120
+ jest.spyOn(global, "fetch").mockImplementation(fetchMock);
121
+ jest.spyOn(console, "error").mockImplementation(() => {});
122
+ });
123
+ afterEach(() => {
124
+ process.env = { ...originalEnv };
125
+ jest.restoreAllMocks();
126
+ });
127
+
128
+ it("uses the server key and ignores client-supplied keys/credentials when auth is disabled", async () => {
129
+ const helper = evaluate<Helper>(generateReactBffHelper("sample"));
130
+ const response = await helper.callFunction({
131
+ request: request({
132
+ headers: {
133
+ "x-functions-key": "attacker-key",
134
+ authorization: "Bearer attacker",
135
+ },
136
+ }),
137
+ method: "GET",
138
+ path: "/api/todo",
139
+ });
140
+ expect(response.status).toBe(200);
141
+ expect(fetchMock.mock.calls[0][0]).toBe(
142
+ "https://backend.example.test/api/todo"
143
+ );
144
+ expect(fetchInit().headers).toEqual({
145
+ "content-type": "application/json",
146
+ "x-functions-key": "server-only-key",
147
+ });
148
+ expect(fetchInit().redirect).toBe("error");
149
+ });
150
+
151
+ it("fails closed for remote backends without a key, including localhost-lookalike hosts", async () => {
152
+ delete process.env.BACKEND_FUNCTIONS_KEY;
153
+ const helper = evaluate<Helper>(generateReactBffHelper("sample"));
154
+ for (const base of [
155
+ "https://backend.example.test",
156
+ "http://localhost:1234@evil.example.test",
157
+ "http://localhost.example.test:7071",
158
+ ]) {
159
+ process.env.BACKEND_FUNCTIONS_BASE_URL = base;
160
+ expect(
161
+ (
162
+ await helper.callFunction({
163
+ request: request(),
164
+ method: "GET",
165
+ path: "/api/todo",
166
+ })
167
+ ).status
168
+ ).toBe(502);
169
+ }
170
+ expect(fetchMock).not.toHaveBeenCalled();
171
+ process.env.BACKEND_FUNCTIONS_BASE_URL = "http://localhost:7071";
172
+ expect(
173
+ (
174
+ await helper.callFunction({
175
+ request: request(),
176
+ method: "GET",
177
+ path: "/api/todo",
178
+ })
179
+ ).status
180
+ ).toBe(200);
181
+ });
182
+
183
+ it("validates input before forwarding and rejects invalid backend output", async () => {
184
+ const helper = evaluate<Helper>(generateReactBffHelper("sample"));
185
+ const config = {
186
+ request: request(),
187
+ method: "POST",
188
+ path: "/api/todo",
189
+ inputSchema: z.object({ title: z.string().min(1) }),
190
+ };
191
+ expect(
192
+ (await helper.callFunction({ ...config, body: { title: "" } })).status
193
+ ).toBe(400);
194
+ expect(fetchMock).not.toHaveBeenCalled();
195
+ expect(
196
+ (
197
+ await helper.callFunction({
198
+ ...config,
199
+ body: { title: "valid" },
200
+ responseSchema: z.object({ count: z.number() }),
201
+ })
202
+ ).status
203
+ ).toBe(502);
204
+ });
205
+
206
+ it("preserves backend errors and bodyless DELETE responses", async () => {
207
+ const helper = evaluate<Helper>(generateReactBffHelper("sample"));
208
+ fetchMock.mockResolvedValueOnce(new Response("forbidden", { status: 403 }));
209
+ expect(
210
+ (
211
+ await helper.callFunction({
212
+ request: request(),
213
+ method: "GET",
214
+ path: "/api/todo",
215
+ })
216
+ ).status
217
+ ).toBe(403);
218
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
219
+ expect(
220
+ await helper.callFunction({
221
+ request: request(),
222
+ method: "DELETE",
223
+ path: "/api/todo/one",
224
+ })
225
+ ).toEqual({ status: 204 });
226
+ });
227
+
228
+ it("forwards normalized SWA/bearer credentials only from the current request", async () => {
229
+ const helper = evaluate<Helper>(
230
+ generateReactBffHelper("sample", {
231
+ schemes: {
232
+ staff: { provider: "swa" },
233
+ external: { provider: "external-token" },
234
+ },
235
+ })
236
+ );
237
+ const principal = {
238
+ identityProvider: "aad",
239
+ userId: "user",
240
+ userRoles: ["authenticated", 42],
241
+ extra: "not-forwarded",
242
+ };
243
+ await helper.callFunction({
244
+ request: request({
245
+ headers: {
246
+ authorization: "Bearer token",
247
+ "x-ms-client-principal": Buffer.from(
248
+ JSON.stringify(principal)
249
+ ).toString("base64"),
250
+ },
251
+ }),
252
+ method: "GET",
253
+ path: "/api/todo",
254
+ });
255
+ const headers = fetchInit().headers;
256
+ expect(headers.authorization).toBe("Bearer token");
257
+ expect(
258
+ JSON.parse(
259
+ Buffer.from(headers["x-ms-client-principal"], "base64").toString()
260
+ )
261
+ ).toEqual({
262
+ identityProvider: "aad",
263
+ userId: "user",
264
+ userDetails: "",
265
+ userRoles: ["authenticated"],
266
+ });
267
+ await helper.callFunction({
268
+ request: request(),
269
+ method: "GET",
270
+ path: "/api/todo",
271
+ });
272
+ expect(fetchInit(1).headers.authorization).toBeUndefined();
273
+ expect(fetchInit(1).headers["x-ms-client-principal"]).toBeUndefined();
274
+ expect(
275
+ (
276
+ await helper.callFunction({
277
+ request: request({ headers: { "x-ms-client-principal": "invalid" } }),
278
+ method: "GET",
279
+ path: "/api/todo",
280
+ })
281
+ ).status
282
+ ).toBe(401);
283
+ expect(fetchMock).toHaveBeenCalledTimes(2);
284
+ });
285
+
286
+ it("converts the HttpOnly JWT cookie to a backend bearer token, without forwarding cookies", async () => {
287
+ const helper = evaluate<Helper>(
288
+ generateReactBffHelper("sample", { provider: "custom-jwt" })
289
+ );
290
+ await helper.callFunction({
291
+ request: request({
292
+ headers: { cookie: "other=ignored; sample-auth-token=jwt-token" },
293
+ }),
294
+ method: "GET",
295
+ path: "/api/todo",
296
+ });
297
+ expect(fetchInit().headers.authorization).toBe("Bearer jwt-token");
298
+ expect(fetchInit().headers.cookie).toBeUndefined();
299
+ });
300
+
301
+ function crud(model = createBasicModelInfo()): Handler {
302
+ const helper = evaluate<Helper>(generateReactBffHelper("sample"));
303
+ return evaluate<{ default: Handler }>(
304
+ generateReactBffRoute(model, "@sample/shared"),
305
+ {
306
+ "../lib/call-function": helper,
307
+ "@sample/shared": {
308
+ todoSchema: z.object({
309
+ id: z.string(),
310
+ title: z.string().min(1),
311
+ createdAt: z.string().optional(),
312
+ updatedAt: z.string().optional(),
313
+ }),
314
+ userSchema: z.object({
315
+ id: z.string(),
316
+ title: z.string(),
317
+ createdAt: z.string().optional(),
318
+ updatedAt: z.string().optional(),
319
+ }),
320
+ },
321
+ }
322
+ ).default;
323
+ }
324
+
325
+ it("handles CRUD, encoded IDs, schema-owned fields and malformed JSON", async () => {
326
+ const handler = crud();
327
+ expect(
328
+ (
329
+ await handler(
330
+ {},
331
+ request({
332
+ method: "POST",
333
+ body: { id: "client-id", title: "Hello", createdAt: "client-time" },
334
+ })
335
+ )
336
+ ).status
337
+ ).toBe(201);
338
+ expect(fetchInit().body).toBe(JSON.stringify({ title: "Hello" }));
339
+ expect(
340
+ (await handler({}, request({ params: { id: "a/b?#" } }))).status
341
+ ).toBe(200);
342
+ expect(fetchMock.mock.calls[1][0]).toBe(
343
+ "https://backend.example.test/api/todo/a%2Fb%3F%23"
344
+ );
345
+ expect(
346
+ (await handler({}, request({ method: "POST", body: "{broken" }))).status
347
+ ).toBe(400);
348
+ expect(
349
+ (
350
+ await handler(
351
+ {},
352
+ request({ method: "POST", params: { id: "one" }, body: {} })
353
+ )
354
+ ).status
355
+ ).toBe(405);
356
+ });
357
+
358
+ it("does not generate writable connector operations for read-only models", async () => {
359
+ const handler = crud(createRdbConnectorModelInfo());
360
+ expect(
361
+ (await handler({}, request({ method: "POST", body: { title: "Hello" } })))
362
+ .status
363
+ ).toBe(405);
364
+ expect(
365
+ (await handler({}, request({ method: "DELETE", params: { id: "one" } })))
366
+ .status
367
+ ).toBe(405);
368
+ expect(fetchMock).not.toHaveBeenCalled();
369
+ });
370
+
371
+ it("keeps login tokens in an HttpOnly cookie and includes the backend key", async () => {
372
+ process.env.NODE_ENV = "production";
373
+ const helper = evaluate<Helper>(
374
+ generateReactBffHelper("sample", { provider: "custom-jwt" })
375
+ );
376
+ const user = {
377
+ id: "one",
378
+ loginId: "test",
379
+ name: "Test",
380
+ email: "",
381
+ roles: [],
382
+ };
383
+ fetchMock.mockResolvedValueOnce(
384
+ new Response(JSON.stringify({ token: "private-token", user }))
385
+ );
386
+ const handler = evaluate<{ default: Handler }>(
387
+ generateReactAuthBff("sample", "@sample/shared", true),
388
+ {
389
+ "../lib/call-function": helper,
390
+ "@sample/shared": {
391
+ AuthUser: z.object({ id: z.string() }),
392
+ LoginRequest: z.object({ loginId: z.string(), password: z.string() }),
393
+ LoginResponse: z.object({ token: z.string(), user: z.unknown() }),
394
+ },
395
+ }
396
+ ).default;
397
+ const result = await handler(
398
+ {},
399
+ request({
400
+ method: "POST",
401
+ params: { action: "login" },
402
+ body: { loginId: "test", password: "password" },
403
+ })
404
+ );
405
+ expect(result.body).toEqual({ user });
406
+ expect(result.headers?.["Set-Cookie"]).toContain("HttpOnly; SameSite=Lax");
407
+ expect(result.headers?.["Set-Cookie"]).toContain("; Secure");
408
+ expect(fetchInit().headers["x-functions-key"]).toBe("server-only-key");
409
+ expect(
410
+ (
411
+ await handler(
412
+ {},
413
+ request({ method: "POST", params: { action: "logout" } })
414
+ )
415
+ ).headers?.["Set-Cookie"]
416
+ ).toContain("Max-Age=0");
417
+ });
418
+
419
+ it("normalizes verified JWT subjects to the frontend user contract without exposing claims", async () => {
420
+ const helper = evaluate<Helper>(
421
+ generateReactBffHelper("sample", { provider: "custom-jwt" })
422
+ );
423
+ const handler = evaluate<{ default: Handler }>(
424
+ generateReactAuthBff("sample", "@sample/shared", true),
425
+ {
426
+ "../lib/call-function": helper,
427
+ "@sample/shared": {
428
+ AuthUser: z.object({ id: z.string(), roles: z.array(z.string()) }),
429
+ LoginRequest: z.object({ loginId: z.string(), password: z.string() }),
430
+ LoginResponse: z.object({ token: z.string(), user: z.unknown() }),
431
+ },
432
+ }
433
+ ).default;
434
+ fetchMock.mockResolvedValueOnce(
435
+ new Response(JSON.stringify({ sub: "one", roles: ["admin"], exp: 123 }))
436
+ );
437
+ const result = await handler(
438
+ {},
439
+ request({
440
+ params: { action: "me" },
441
+ headers: { cookie: "sample-auth-token=private-token" },
442
+ })
443
+ );
444
+ expect(result).toMatchObject({
445
+ status: 200,
446
+ body: { id: "one", roles: ["admin"] },
447
+ });
448
+ expect(fetchInit().headers.authorization).toBe("Bearer private-token");
449
+ expect(result.body).not.toHaveProperty("sub");
450
+ expect(result.body).not.toHaveProperty("exp");
451
+ fetchMock.mockResolvedValueOnce(
452
+ new Response(JSON.stringify({ roles: ["admin"] }))
453
+ );
454
+ expect(
455
+ (await handler({}, request({ params: { action: "me" } }))).status
456
+ ).toBe(502);
457
+ });
458
+ });
459
+
460
+ describe("React project integration", () => {
461
+ const originalCwd = process.cwd();
462
+ let project: string;
463
+ beforeEach(() => {
464
+ project = fs.mkdtempSync(path.join(originalCwd, ".tmp-react-test-"));
465
+ createReactProject(project, "sample");
466
+ createReactBffProject(project, "sample", "npm");
467
+ fs.mkdirSync(path.join(project, "functions", "src"), { recursive: true });
468
+ fs.mkdirSync(path.join(project, "shared", "models"), { recursive: true });
469
+ fs.writeFileSync(
470
+ path.join(project, "swallowkit.config.js"),
471
+ buildSwallowKitConfigSource("typescript", "react")
472
+ );
473
+ fs.writeFileSync(
474
+ path.join(project, "staticwebapp.config.json"),
475
+ JSON.stringify(buildReactSwaConfig())
476
+ );
477
+ fs.writeFileSync(
478
+ path.join(project, "shared", "package.json"),
479
+ JSON.stringify({ name: "@sample/shared" })
480
+ );
481
+ fs.writeFileSync(
482
+ path.join(project, "shared", "index.ts"),
483
+ "export { Todo } from './models/todo';\n"
484
+ );
485
+ fs.writeFileSync(
486
+ path.join(project, "shared", "models", "todo.ts"),
487
+ `import { z } from 'zod/v4';\nexport const Todo = z.object({ id: z.string(), title: z.string().min(1), createdAt: z.string().optional(), updatedAt: z.string().optional() });\n`
488
+ );
489
+ process.chdir(project);
490
+ });
491
+ afterEach(() => {
492
+ process.chdir(originalCwd);
493
+ fs.rmSync(project, { recursive: true, force: true });
494
+ jest.restoreAllMocks();
495
+ });
496
+
497
+ it("persists the frontend choice and preserves Next.js for existing projects", () => {
498
+ expect(getFrontendFramework()).toBe("react");
499
+ expect(loadConfig(undefined, true, true).frontend?.framework).toBe("react");
500
+ fs.rmSync(path.join(project, "swallowkit.config.js"));
501
+ fs.writeFileSync(path.join(project, "swallowkit.config.json"), "{}");
502
+ expect(getFrontendFramework()).toBe("nextjs");
503
+ expect(
504
+ validateConfig({ frontend: { framework: "invalid" as never } }).valid
505
+ ).toBe(false);
506
+ });
507
+
508
+ it("plans and applies BFF bindings, React pages and metadata through the common path", async () => {
509
+ const plan = await planScaffoldOperation({ model: "todo" });
510
+ expect(plan.requiresApproval).toBe(false);
511
+ expect(plan.operations).toEqual(
512
+ expect.arrayContaining([
513
+ expect.objectContaining({
514
+ path: "bff/src/todo/index.ts",
515
+ ownership: "managed",
516
+ }),
517
+ expect.objectContaining({
518
+ path: "bff/todo/function.json",
519
+ ownership: "managed",
520
+ }),
521
+ expect.objectContaining({ path: "src/pages/todo/routes.tsx" }),
522
+ expect.objectContaining({ path: "functions/src/todo.ts" }),
523
+ ])
524
+ );
525
+ expect(
526
+ plan.operations.some((operation) => operation.path.startsWith("app/"))
527
+ ).toBe(false);
528
+ expect(
529
+ fs.existsSync(path.join(project, "bff", "src", "todo", "index.ts"))
530
+ ).toBe(false);
531
+ await applyScaffoldOperation({ planId: plan.planId });
532
+ const { manifest } = await buildProjectManifest(project);
533
+ expect(manifest.frontendFramework).toBe("react");
534
+ expect(
535
+ manifest.routes.find((route) => route.name === "Todo-bff-detail")
536
+ ).toMatchObject({ filePath: "bff/src/todo/index.ts", exists: true });
537
+ expect(
538
+ (await validateProject(project)).violations.filter(
539
+ (violation) => violation.severity === "error"
540
+ )
541
+ ).toEqual([]);
542
+ const page = fs.readFileSync(
543
+ path.join(project, "src", "pages", "todo", "[id]", "page.tsx"),
544
+ "utf8"
545
+ );
546
+ expect(page).toContain("react-router-dom");
547
+ expect(page).not.toContain("next/");
548
+ const secondPlan = await planScaffoldOperation({ model: "todo" });
549
+ expect(secondPlan.requiresApproval).toBe(false);
550
+ });
551
+
552
+ it("generates only server artifacts with api-only", async () => {
553
+ const plan = await planScaffoldOperation({ model: "todo", apiOnly: true });
554
+ expect(
555
+ plan.operations.some((operation) => operation.path.startsWith("bff/"))
556
+ ).toBe(true);
557
+ expect(
558
+ plan.operations.some((operation) =>
559
+ operation.path.startsWith("src/pages/")
560
+ )
561
+ ).toBe(false);
562
+ });
563
+
564
+ it.each<["csharp" | "python", string]>([
565
+ ["csharp", "functions/Crud/TodoCrudFunctions.cs"],
566
+ ["python", "functions/blueprints/todo.py"],
567
+ ])(
568
+ "plans the same managed BFF and React UI with a %s standalone backend",
569
+ async (language, backendPath) => {
570
+ const source = buildSwallowKitConfigSource(language, "react");
571
+ const config = new Function(
572
+ source.replace("module.exports =", "return")
573
+ )();
574
+ fs.rmSync(path.join(project, "swallowkit.config.js"));
575
+ fs.writeFileSync(
576
+ path.join(project, "swallowkit.config.json"),
577
+ JSON.stringify(config)
578
+ );
579
+ if (language === "python") {
580
+ fs.writeFileSync(
581
+ path.join(project, "functions", "function_app.py"),
582
+ "import azure.functions as func\napp = func.FunctionApp()\n# SwallowKit scaffold registrations\n"
583
+ );
584
+ }
585
+ const plan = await planScaffoldOperation({ model: "todo" });
586
+ expect(plan.operations).toEqual(
587
+ expect.arrayContaining([
588
+ expect.objectContaining({ path: backendPath }),
589
+ expect.objectContaining({ path: "bff/src/todo/index.ts" }),
590
+ expect.objectContaining({ path: "bff/todo/function.json" }),
591
+ expect.objectContaining({ path: "src/pages/todo/routes.tsx" }),
592
+ ])
593
+ );
594
+ expect(
595
+ plan.operations.some((operation) => operation.path.startsWith("app/"))
596
+ ).toBe(false);
597
+ expect(fs.existsSync(path.join(project, backendPath))).toBe(false);
598
+ expect(getFrontendFramework()).toBe("react");
599
+ }
600
+ );
601
+
602
+ it("reports server dependencies and settings in browser code", async () => {
603
+ fs.writeFileSync(
604
+ path.join(project, "src", "unsafe.ts"),
605
+ "import { callFunction } from '../bff/src/lib/call-function';\nconst key = import.meta.env.VITE_BACKEND_FUNCTIONS_KEY;\n"
606
+ );
607
+ const result = await validateProject(project);
608
+ expect(result.violations).toEqual(
609
+ expect.arrayContaining([
610
+ expect.objectContaining({
611
+ code: "forbidden-client-backend-dependency",
612
+ severity: "error",
613
+ location: { path: "src/unsafe.ts" },
614
+ }),
615
+ ])
616
+ );
617
+ });
618
+
619
+ it("detects manifest drift when the frontend changes without migrating routes", async () => {
620
+ const plan = await planScaffoldOperation({ model: "todo" });
621
+ await applyScaffoldOperation({ planId: plan.planId });
622
+ fs.rmSync(path.join(project, "swallowkit.config.js"));
623
+ fs.writeFileSync(
624
+ path.join(project, "swallowkit.config.json"),
625
+ JSON.stringify({ frontend: { framework: "nextjs" } })
626
+ );
627
+ expect((await validateProject(project)).violations).toEqual(
628
+ expect.arrayContaining([
629
+ expect.objectContaining({ code: "manifest-out-of-date" }),
630
+ ])
631
+ );
632
+ });
633
+
634
+ it.each(["swa", "external-token", "custom-jwt"])(
635
+ "generates %s auth without Next.js imports",
636
+ async (provider) => {
637
+ await captureConsoleMessages(async () => addAuthCommand({ provider }));
638
+ const helper = fs.readFileSync(
639
+ path.join(project, "bff", "src", "lib", "call-function.ts"),
640
+ "utf8"
641
+ );
642
+ expect(helper).not.toContain("next/");
643
+ expect(helper).toContain(
644
+ provider === "swa"
645
+ ? "const forwardSwa = true"
646
+ : "const forwardBearer = true"
647
+ );
648
+ const context = fs.readFileSync(
649
+ path.join(project, "lib", "auth", "auth-context.tsx"),
650
+ "utf8"
651
+ );
652
+ expect(context).not.toContain("next/");
653
+ expect(fs.existsSync(path.join(project, "proxy.ts"))).toBe(false);
654
+ expect(fs.existsSync(path.join(project, "app"))).toBe(false);
655
+ if (provider !== "external-token")
656
+ expect(
657
+ fs.existsSync(path.join(project, "src", "pages", "login.tsx"))
658
+ ).toBe(true);
659
+ // JSON config avoids Jest's CJS module cache when reading the just-updated config.
660
+ const source = fs.readFileSync(
661
+ path.join(project, "swallowkit.config.js"),
662
+ "utf8"
663
+ );
664
+ const config = new Function(
665
+ source.replace("module.exports =", "return")
666
+ )();
667
+ fs.rmSync(path.join(project, "swallowkit.config.js"));
668
+ fs.writeFileSync(
669
+ path.join(project, "swallowkit.config.json"),
670
+ JSON.stringify(config)
671
+ );
672
+ const { manifest } = await buildProjectManifest(project);
673
+ expect(
674
+ manifest.routes.find((route) => route.name === "auth-login")?.exists
675
+ ).toBe(provider === "custom-jwt");
676
+ expect(
677
+ manifest.routes.find((route) => route.name === "auth-me")?.exists
678
+ ).toBe(provider !== "swa");
679
+ }
680
+ );
681
+
682
+ it("adds multiple named credential transports and model-scoped providers", async () => {
683
+ fs.rmSync(path.join(project, "swallowkit.config.js"));
684
+ fs.writeFileSync(
685
+ path.join(project, "swallowkit.config.json"),
686
+ JSON.stringify({
687
+ frontend: { framework: "react" },
688
+ auth: {
689
+ schemes: {},
690
+ authorization: { defaultPolicy: "anonymous", policies: {} },
691
+ },
692
+ })
693
+ );
694
+ await captureConsoleMessages(async () =>
695
+ addAuthCommand({
696
+ provider: "swa",
697
+ scheme: "staff",
698
+ allowedProviders: ["github"],
699
+ })
700
+ );
701
+ await captureConsoleMessages(async () =>
702
+ addAuthCommand({ provider: "external-token", scheme: "customers" })
703
+ );
704
+ const helper = fs.readFileSync(
705
+ path.join(project, "bff", "src", "lib", "call-function.ts"),
706
+ "utf8"
707
+ );
708
+ expect(helper).toContain("const forwardSwa = true");
709
+ expect(helper).toContain("const forwardBearer = true");
710
+ expect(
711
+ fs.existsSync(
712
+ path.join(
713
+ project,
714
+ "lib",
715
+ "auth",
716
+ "schemes",
717
+ "customers",
718
+ "token-adapter.ts"
719
+ )
720
+ )
721
+ ).toBe(true);
722
+ });
723
+
724
+ it("describes framework-specific paths, development and deployment to agents", () => {
725
+ const context = {
726
+ projectName: "sample",
727
+ backendLanguage: "typescript" as const,
728
+ packageManager: "npm" as const,
729
+ frontend: "react" as const,
730
+ };
731
+ const agents = buildAgentsMd(context);
732
+ expect(agents).toContain("React + Vite SPA");
733
+ expect(agents).toContain("SWA managed Functions");
734
+ expect(agents).toContain("never directly to standalone Functions");
735
+ expect(agents).toContain("do not start/restart");
736
+ expect(agents).not.toContain("Next.js API");
737
+ expect(
738
+ buildPathSpecificInstructions(context)["bff-routes.instructions.md"]
739
+ ).toContain('applyTo: "bff/**"');
740
+ });
741
+ });
742
+
743
+ describe("React deployment and development contracts", () => {
744
+ it("refuses occupied ports without stopping the existing listener", async () => {
745
+ const server = net.createServer();
746
+ await new Promise<void>((resolve) =>
747
+ server.listen(0, "127.0.0.1", resolve)
748
+ );
749
+ try {
750
+ const address = server.address();
751
+ if (!address || typeof address === "string")
752
+ throw new Error("Expected TCP address");
753
+ await expect(
754
+ assertDevPortAvailable(address.port, "127.0.0.1")
755
+ ).rejects.toThrow("unavailable");
756
+ expect(server.listening).toBe(true);
757
+ } finally {
758
+ await new Promise<void>((resolve, reject) =>
759
+ server.close((error) => (error ? reject(error) : resolve()))
760
+ );
761
+ }
762
+ });
763
+
764
+ it.each(["npm", "pnpm"] as const)(
765
+ "deploys prebuilt SPA and managed BFF using %s, never the standalone backend",
766
+ (pm) => {
767
+ for (const workflow of [
768
+ buildGitHubSwaWorkflow(pm, pm, "react"),
769
+ buildAzureSwaPipeline(pm, pm, "react"),
770
+ ]) {
771
+ expect(workflow).toContain("app_location: dist");
772
+ expect(workflow).toContain("api_location: bff");
773
+ expect(workflow).toContain("skip_app_build: true");
774
+ expect(workflow).toContain("skip_api_build: true");
775
+ expect(workflow).toContain("bff/**");
776
+ expect(workflow).toContain("staticwebapp.config.json");
777
+ expect(workflow).not.toContain("api_location: functions");
778
+ expect(workflow).not.toContain("next build");
779
+ }
780
+ expect(getBuildScript(pm, "react")).toContain("copyFileSync");
781
+ expect(getBuildScript(pm, "react")).not.toContain("next ");
782
+ }
783
+ );
784
+
785
+ it("binds managed HTTP functions and excludes APIs from SPA fallback", () => {
786
+ const binding = JSON.parse(
787
+ generateManagedFunctionConfig("todo/{id?}", ["GET"], "todo")
788
+ );
789
+ expect(binding.scriptFile).toBe("../dist/todo/index.js");
790
+ expect(binding.bindings[0]).toMatchObject({
791
+ authLevel: "anonymous",
792
+ route: "todo/{id?}",
793
+ type: "httpTrigger",
794
+ methods: ["get"],
795
+ });
796
+ expect(binding.bindings[1]).toEqual({
797
+ type: "http",
798
+ direction: "out",
799
+ name: "$return",
800
+ });
801
+ expect(buildReactSwaConfig().navigationFallback.exclude).toEqual(
802
+ expect.arrayContaining(["/api/*", "/.auth/*"])
803
+ );
804
+ });
805
+
806
+ it("routes SWA only to the managed BFF and starts Vite instead of Next.js", () => {
807
+ expect(buildReactDevArgs("npm", "3100", "127.0.0.1")).toEqual([
808
+ "vite",
809
+ "--port",
810
+ "3100",
811
+ "--host",
812
+ "127.0.0.1",
813
+ "--strictPort",
814
+ ]);
815
+ const args = buildSwaStartArgs(undefined, "3100", "4285", "7079");
816
+ expect(args).toContain("http://localhost:7079");
817
+ expect(args).not.toContain("http://localhost:7071");
818
+ });
819
+ });