swallowkit 1.1.2 → 2.0.0-alpha.2
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.
- package/README.ja.md +61 -7
- package/README.md +61 -7
- package/dist/cli/commands/add-auth.d.ts.map +1 -1
- package/dist/cli/commands/add-auth.js +66 -21
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/dev.d.ts +4 -1
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +108 -44
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -5
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +83 -39
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +1 -1
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +38 -15
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +6 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/core/config.d.ts +2 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +19 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/project/agent-instructions.d.ts +2 -1
- package/dist/core/project/agent-instructions.d.ts.map +1 -1
- package/dist/core/project/agent-instructions.js +12 -0
- package/dist/core/project/agent-instructions.js.map +1 -1
- package/dist/core/project/capabilities.d.ts +7 -0
- package/dist/core/project/capabilities.d.ts.map +1 -1
- package/dist/core/project/capabilities.js +7 -0
- package/dist/core/project/capabilities.js.map +1 -1
- package/dist/core/project/manifest.d.ts +3 -1
- package/dist/core/project/manifest.d.ts.map +1 -1
- package/dist/core/project/manifest.js +47 -25
- package/dist/core/project/manifest.js.map +1 -1
- package/dist/core/project/react-project.d.ts +26 -0
- package/dist/core/project/react-project.d.ts.map +1 -0
- package/dist/core/project/react-project.js +422 -0
- package/dist/core/project/react-project.js.map +1 -0
- package/dist/core/project/validation.d.ts.map +1 -1
- package/dist/core/project/validation.js +32 -10
- package/dist/core/project/validation.js.map +1 -1
- package/dist/core/scaffold/react-bff-generator.d.ts +10 -0
- package/dist/core/scaffold/react-bff-generator.d.ts.map +1 -0
- package/dist/core/scaffold/react-bff-generator.js +252 -0
- package/dist/core/scaffold/react-bff-generator.js.map +1 -0
- package/dist/core/scaffold/react-ui-generator.d.ts +6 -0
- package/dist/core/scaffold/react-ui-generator.d.ts.map +1 -0
- package/dist/core/scaffold/react-ui-generator.js +41 -0
- package/dist/core/scaffold/react-ui-generator.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/machine/index.js +2 -2
- package/dist/machine/index.js.map +1 -1
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/package-manager.d.ts +2 -2
- package/dist/utils/package-manager.d.ts.map +1 -1
- package/dist/utils/package-manager.js +5 -2
- package/dist/utils/package-manager.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/agent-instructions.test.ts +1 -1
- package/src/__tests__/init.test.ts +29 -5
- package/src/__tests__/package-manager.test.ts +15 -0
- package/src/__tests__/react.test.ts +819 -0
- package/src/__tests__/release-metadata.test.ts +121 -0
- package/src/cli/commands/add-auth.ts +67 -23
- package/src/cli/commands/dev.ts +109 -46
- package/src/cli/commands/init.ts +75 -35
- package/src/cli/commands/scaffold.ts +40 -19
- package/src/cli/index.ts +6 -3
- package/src/core/config.ts +21 -1
- package/src/core/project/agent-instructions.ts +14 -1
- package/src/core/project/agent-templates/AGENTS.md +24 -9
- package/src/core/project/agent-templates/bff-routes.instructions.md +5 -3
- package/src/core/project/capabilities.ts +14 -0
- package/src/core/project/manifest.ts +52 -27
- package/src/core/project/react-project.ts +507 -0
- package/src/core/project/validation.ts +32 -10
- package/src/core/scaffold/react-bff-generator.ts +292 -0
- package/src/core/scaffold/react-ui-generator.ts +51 -0
- package/src/index.ts +3 -1
- package/src/machine/index.ts +2 -2
- package/src/types/index.ts +4 -0
- package/src/utils/package-manager.ts +6 -3
|
@@ -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
|
+
});
|