swallowkit 1.0.0-beta.2 → 1.0.0-beta.20
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/LICENSE +21 -21
- package/README.ja.md +312 -215
- package/README.md +369 -216
- package/dist/__tests__/fixtures.d.ts +22 -0
- package/dist/__tests__/fixtures.d.ts.map +1 -0
- package/dist/__tests__/fixtures.js +146 -0
- package/dist/__tests__/fixtures.js.map +1 -0
- package/dist/cli/commands/add-auth.d.ts +10 -0
- package/dist/cli/commands/add-auth.d.ts.map +1 -0
- package/dist/cli/commands/add-auth.js +444 -0
- package/dist/cli/commands/add-auth.js.map +1 -0
- package/dist/cli/commands/add-connector.d.ts +20 -0
- package/dist/cli/commands/add-connector.d.ts.map +1 -0
- package/dist/cli/commands/add-connector.js +163 -0
- package/dist/cli/commands/add-connector.js.map +1 -0
- package/dist/cli/commands/create-model.d.ts +1 -4
- package/dist/cli/commands/create-model.d.ts.map +1 -1
- package/dist/cli/commands/create-model.js +21 -82
- package/dist/cli/commands/create-model.js.map +1 -1
- package/dist/cli/commands/dev-seeds.d.ts +35 -0
- package/dist/cli/commands/dev-seeds.d.ts.map +1 -0
- package/dist/cli/commands/dev-seeds.js +292 -0
- package/dist/cli/commands/dev-seeds.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +19 -0
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +476 -117
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/index.d.ts +1 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +3 -1
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/init.d.ts +13 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +2627 -1708
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +3 -0
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +617 -129
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +162 -42
- package/dist/cli/index.js.map +1 -1
- package/dist/core/config.d.ts +8 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +90 -4
- package/dist/core/config.js.map +1 -1
- package/dist/core/mock/connector-mock-server.d.ts +101 -0
- package/dist/core/mock/connector-mock-server.d.ts.map +1 -0
- package/dist/core/mock/connector-mock-server.js +480 -0
- package/dist/core/mock/connector-mock-server.js.map +1 -0
- package/dist/core/mock/zod-mock-generator.d.ts +14 -0
- package/dist/core/mock/zod-mock-generator.d.ts.map +1 -0
- package/dist/core/mock/zod-mock-generator.js +163 -0
- package/dist/core/mock/zod-mock-generator.js.map +1 -0
- package/dist/core/operations/create-model.d.ts +15 -0
- package/dist/core/operations/create-model.d.ts.map +1 -0
- package/dist/core/operations/create-model.js +171 -0
- package/dist/core/operations/create-model.js.map +1 -0
- package/dist/core/operations/runtime.d.ts +32 -0
- package/dist/core/operations/runtime.d.ts.map +1 -0
- package/dist/core/operations/runtime.js +225 -0
- package/dist/core/operations/runtime.js.map +1 -0
- package/dist/core/operations/scaffold-machine.d.ts +16 -0
- package/dist/core/operations/scaffold-machine.d.ts.map +1 -0
- package/dist/core/operations/scaffold-machine.js +63 -0
- package/dist/core/operations/scaffold-machine.js.map +1 -0
- package/dist/core/project/manifest.d.ts +92 -0
- package/dist/core/project/manifest.d.ts.map +1 -0
- package/dist/core/project/manifest.js +321 -0
- package/dist/core/project/manifest.js.map +1 -0
- package/dist/core/project/validation.d.ts +20 -0
- package/dist/core/project/validation.d.ts.map +1 -0
- package/dist/core/project/validation.js +204 -0
- package/dist/core/project/validation.js.map +1 -0
- package/dist/core/scaffold/auth-generator.d.ts +38 -0
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -0
- package/dist/core/scaffold/auth-generator.js +1244 -0
- package/dist/core/scaffold/auth-generator.js.map +1 -0
- package/dist/core/scaffold/connector-functions-generator.d.ts +41 -0
- package/dist/core/scaffold/connector-functions-generator.d.ts.map +1 -0
- package/dist/core/scaffold/connector-functions-generator.js +1027 -0
- package/dist/core/scaffold/connector-functions-generator.js.map +1 -0
- package/dist/core/scaffold/functions-generator.d.ts +7 -1
- package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
- package/dist/core/scaffold/functions-generator.js +920 -213
- package/dist/core/scaffold/functions-generator.js.map +1 -1
- package/dist/core/scaffold/model-parser.d.ts +20 -1
- package/dist/core/scaffold/model-parser.d.ts.map +1 -1
- package/dist/core/scaffold/model-parser.js +329 -135
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/dist/core/scaffold/nextjs-generator.d.ts +8 -0
- package/dist/core/scaffold/nextjs-generator.d.ts.map +1 -1
- package/dist/core/scaffold/nextjs-generator.js +314 -182
- package/dist/core/scaffold/nextjs-generator.js.map +1 -1
- package/dist/core/scaffold/openapi-generator.d.ts +3 -0
- package/dist/core/scaffold/openapi-generator.d.ts.map +1 -0
- package/dist/core/scaffold/openapi-generator.js +190 -0
- package/dist/core/scaffold/openapi-generator.js.map +1 -0
- package/dist/core/scaffold/ui-generator.d.ts +10 -4
- package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
- package/dist/core/scaffold/ui-generator.js +768 -663
- package/dist/core/scaffold/ui-generator.js.map +1 -1
- package/dist/database/base-model.d.ts +3 -3
- package/dist/database/base-model.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/machine/contracts.d.ts +16 -0
- package/dist/machine/contracts.d.ts.map +1 -0
- package/dist/machine/contracts.js +3 -0
- package/dist/machine/contracts.js.map +1 -0
- package/dist/machine/errors.d.ts +11 -0
- package/dist/machine/errors.d.ts.map +1 -0
- package/dist/machine/errors.js +34 -0
- package/dist/machine/errors.js.map +1 -0
- package/dist/machine/index.d.ts +3 -0
- package/dist/machine/index.d.ts.map +1 -0
- package/dist/machine/index.js +156 -0
- package/dist/machine/index.js.map +1 -0
- package/dist/mcp/index.d.ts +25 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +184 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/types/index.d.ts +65 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/package-manager.d.ts +109 -0
- package/dist/utils/package-manager.d.ts.map +1 -0
- package/dist/utils/package-manager.js +215 -0
- package/dist/utils/package-manager.js.map +1 -0
- package/package.json +85 -73
- package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +1139 -0
- package/src/__tests__/__snapshots__/nextjs-generator.test.ts.snap +194 -0
- package/src/__tests__/__snapshots__/ui-generator.test.ts.snap +532 -0
- package/src/__tests__/auth.test.ts +654 -0
- package/src/__tests__/config.test.ts +263 -0
- package/src/__tests__/connector-functions-generator.test.ts +288 -0
- package/src/__tests__/connector-mock-server.test.ts +439 -0
- package/src/__tests__/connector-model-bff.test.ts +162 -0
- package/src/__tests__/dev-seeds.test.ts +112 -0
- package/src/__tests__/dev.test.ts +148 -0
- package/src/__tests__/fixtures.ts +144 -0
- package/src/__tests__/functions-generator.test.ts +237 -0
- package/src/__tests__/init.test.ts +80 -0
- package/src/__tests__/machine.test.ts +212 -0
- package/src/__tests__/mcp.test.ts +56 -0
- package/src/__tests__/model-parser.test.ts +72 -0
- package/src/__tests__/nextjs-generator.test.ts +97 -0
- package/src/__tests__/openapi-generator.test.ts +43 -0
- package/src/__tests__/package-manager.test.ts +189 -0
- package/src/__tests__/scaffold.test.ts +39 -0
- package/src/__tests__/string-utils.test.ts +75 -0
- package/src/__tests__/ui-generator.test.ts +144 -0
- package/src/__tests__/zod-mock-generator.test.ts +132 -0
- package/src/cli/commands/add-auth.ts +500 -0
- package/src/cli/commands/add-connector.ts +158 -0
- package/src/cli/commands/create-model.ts +62 -0
- package/src/cli/commands/dev-seeds.ts +358 -0
- package/src/cli/commands/dev.ts +962 -0
- package/src/cli/commands/index.ts +9 -0
- package/src/cli/commands/init.ts +3371 -0
- package/src/cli/commands/provision.ts +193 -0
- package/src/cli/commands/scaffold.ts +1211 -0
- package/src/cli/index.ts +191 -0
- package/src/core/config.ts +308 -0
- package/src/core/mock/connector-mock-server.ts +555 -0
- package/src/core/mock/zod-mock-generator.ts +205 -0
- package/src/core/operations/create-model.ts +174 -0
- package/src/core/operations/runtime.ts +235 -0
- package/src/core/operations/scaffold-machine.ts +91 -0
- package/src/core/project/manifest.ts +402 -0
- package/src/core/project/validation.ts +221 -0
- package/src/core/scaffold/auth-generator.ts +1284 -0
- package/src/core/scaffold/connector-functions-generator.ts +1128 -0
- package/src/core/scaffold/functions-generator.ts +970 -0
- package/src/core/scaffold/model-parser.ts +841 -0
- package/src/core/scaffold/nextjs-generator.ts +370 -0
- package/src/core/scaffold/openapi-generator.ts +212 -0
- package/src/core/scaffold/ui-generator.ts +1061 -0
- package/src/database/base-model.ts +184 -0
- package/src/database/client.ts +140 -0
- package/src/database/repository.ts +104 -0
- package/src/database/runtime-check.ts +25 -0
- package/src/index.ts +27 -0
- package/src/machine/contracts.ts +17 -0
- package/src/machine/errors.ts +34 -0
- package/src/machine/index.ts +173 -0
- package/src/mcp/index.ts +185 -0
- package/src/types/index.ts +134 -0
- package/src/utils/package-manager.ts +229 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { createBasicModelInfo } from "./fixtures";
|
|
5
|
+
import {
|
|
6
|
+
buildSeedTemplateDocument,
|
|
7
|
+
getContainerNameForModel,
|
|
8
|
+
loadDevSeedFiles,
|
|
9
|
+
normalizeSeedIdentifier,
|
|
10
|
+
parseSeedDocuments,
|
|
11
|
+
} from "../cli/commands/dev-seeds";
|
|
12
|
+
|
|
13
|
+
describe("dev seed helpers", () => {
|
|
14
|
+
const originalCwd = process.cwd();
|
|
15
|
+
let tempDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-dev-seeds-"));
|
|
19
|
+
process.chdir(tempDir);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
process.chdir(originalCwd);
|
|
24
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("builds Cosmos container names from model names", () => {
|
|
28
|
+
expect(getContainerNameForModel(createBasicModelInfo())).toBe("Todos");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("normalizes seed identifiers across common naming styles", () => {
|
|
32
|
+
expect(normalizeSeedIdentifier("todo-items")).toBe("todoitems");
|
|
33
|
+
expect(normalizeSeedIdentifier("Todo_Items")).toBe("todoitems");
|
|
34
|
+
expect(normalizeSeedIdentifier("todoSchema")).toBe("todoschema");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("wraps a single JSON object into a document array", () => {
|
|
38
|
+
expect(parseSeedDocuments('{"id":"todo-001","title":"Hello"}', "todo.json")).toEqual([
|
|
39
|
+
{ id: "todo-001", title: "Hello" },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("loads matching seed files for an environment", async () => {
|
|
44
|
+
const seedsDir = path.join(tempDir, "dev-seeds", "local");
|
|
45
|
+
fs.mkdirSync(seedsDir, { recursive: true });
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
path.join(seedsDir, "todo.json"),
|
|
48
|
+
JSON.stringify([{ id: "todo-001", title: "First task" }], null, 2),
|
|
49
|
+
"utf-8"
|
|
50
|
+
);
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(seedsDir, "ignored.json"),
|
|
53
|
+
JSON.stringify([{ id: "ignored-001" }], null, 2),
|
|
54
|
+
"utf-8"
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const loaded = await loadDevSeedFiles("local", [createBasicModelInfo()]);
|
|
58
|
+
|
|
59
|
+
expect(loaded).toHaveLength(1);
|
|
60
|
+
expect(loaded[0]).toMatchObject({
|
|
61
|
+
containerName: "Todos",
|
|
62
|
+
documents: [{ id: "todo-001", title: "First task" }],
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects seed documents without ids", async () => {
|
|
67
|
+
const seedsDir = path.join(tempDir, "dev-seeds", "local");
|
|
68
|
+
fs.mkdirSync(seedsDir, { recursive: true });
|
|
69
|
+
fs.writeFileSync(path.join(seedsDir, "todo.json"), JSON.stringify([{ title: "Missing id" }], null, 2), "utf-8");
|
|
70
|
+
|
|
71
|
+
await expect(loadDevSeedFiles("local", [createBasicModelInfo()])).rejects.toThrow(/non-empty string id/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("builds nested template documents from related schemas", () => {
|
|
75
|
+
const category = createBasicModelInfo({
|
|
76
|
+
name: "Category",
|
|
77
|
+
displayName: "Category",
|
|
78
|
+
schemaName: "categorySchema",
|
|
79
|
+
filePath: "/models/category.ts",
|
|
80
|
+
fields: [
|
|
81
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
82
|
+
{ name: "name", type: "string", isOptional: false, isArray: false },
|
|
83
|
+
],
|
|
84
|
+
hasCreatedAt: false,
|
|
85
|
+
hasUpdatedAt: false,
|
|
86
|
+
});
|
|
87
|
+
const todo = createBasicModelInfo({
|
|
88
|
+
fields: [
|
|
89
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
90
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
91
|
+
{
|
|
92
|
+
name: "category",
|
|
93
|
+
type: "object",
|
|
94
|
+
isOptional: true,
|
|
95
|
+
isArray: false,
|
|
96
|
+
isNestedSchema: true,
|
|
97
|
+
nestedModelName: "Category",
|
|
98
|
+
nestedSchemaName: "categorySchema",
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(buildSeedTemplateDocument(todo, [todo, category])).toEqual({
|
|
104
|
+
id: "todo-001",
|
|
105
|
+
title: "todo-title-sample",
|
|
106
|
+
category: {
|
|
107
|
+
id: "category-001",
|
|
108
|
+
name: "category-name-sample",
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import {
|
|
3
|
+
buildFunctionsStartArgs,
|
|
4
|
+
buildNextDevArgs,
|
|
5
|
+
buildDevCommand,
|
|
6
|
+
buildPythonFunctionsEnv,
|
|
7
|
+
DevOptions,
|
|
8
|
+
getPythonVirtualEnvPaths,
|
|
9
|
+
} from "../cli/commands/dev";
|
|
10
|
+
import { createProgram, normalizeDevCommandArgv } from "../cli/index";
|
|
11
|
+
|
|
12
|
+
describe("dev command helpers", () => {
|
|
13
|
+
it("passes the requested port to Azure Functions Core Tools", () => {
|
|
14
|
+
expect(buildFunctionsStartArgs("7076")).toEqual(["start", "--port", "7076"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("uses webpack mode for npm-based Next.js dev", () => {
|
|
18
|
+
expect(buildNextDevArgs("npm", "3012")).toEqual(["next", "dev", "--port", "3012", "--webpack"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("uses pnpm exec for pnpm-based Next.js dev", () => {
|
|
22
|
+
expect(buildNextDevArgs("pnpm", "3012")).toEqual(["exec", "next", "dev", "--port", "3012", "--webpack"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("builds Python virtual environment paths under functions/.venv", () => {
|
|
26
|
+
const functionsDir = path.join("C:\\repo", "functions");
|
|
27
|
+
const paths = getPythonVirtualEnvPaths(functionsDir);
|
|
28
|
+
|
|
29
|
+
expect(paths.venvDir).toBe(path.join(functionsDir, ".venv"));
|
|
30
|
+
expect(paths.binDir.endsWith(process.platform === "win32" ? path.join(".venv", "Scripts") : path.join(".venv", "bin"))).toBe(true);
|
|
31
|
+
expect(paths.pythonExecutable.endsWith(process.platform === "win32" ? path.join("Scripts", "python.exe") : path.join("bin", "python"))).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("injects Python virtual environment settings for Functions", () => {
|
|
35
|
+
const functionsDir = path.join("C:\\repo", "functions");
|
|
36
|
+
const env = buildPythonFunctionsEnv({ PATH: "C:\\Windows\\System32" }, functionsDir);
|
|
37
|
+
const expectedBinDir = getPythonVirtualEnvPaths(functionsDir).binDir;
|
|
38
|
+
|
|
39
|
+
expect(env.VIRTUAL_ENV).toBe(path.join(functionsDir, ".venv"));
|
|
40
|
+
expect(env.languageWorkers__python__defaultExecutablePath).toBe(
|
|
41
|
+
getPythonVirtualEnvPaths(functionsDir).pythonExecutable
|
|
42
|
+
);
|
|
43
|
+
expect(env.PATH?.startsWith(`${expectedBinDir}${path.delimiter}`)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("dev CLI parser", () => {
|
|
48
|
+
async function parseDevOptions(argv: string[]): Promise<DevOptions> {
|
|
49
|
+
let capturedOptions: DevOptions | undefined;
|
|
50
|
+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
|
|
51
|
+
const program = createProgram(buildDevCommand(async (options) => {
|
|
52
|
+
capturedOptions = options;
|
|
53
|
+
}, () => undefined));
|
|
54
|
+
|
|
55
|
+
program.exitOverride();
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await program.parseAsync(normalizeDevCommandArgv(argv));
|
|
59
|
+
} finally {
|
|
60
|
+
logSpy.mockRestore();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(capturedOptions).toBeDefined();
|
|
64
|
+
return capturedOptions!;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("normalizes dev options placed before the subcommand", () => {
|
|
68
|
+
expect(
|
|
69
|
+
normalizeDevCommandArgv([
|
|
70
|
+
"node",
|
|
71
|
+
"swallowkit",
|
|
72
|
+
"--seed-env",
|
|
73
|
+
"local",
|
|
74
|
+
"--mock-connectors",
|
|
75
|
+
"dev",
|
|
76
|
+
"--port",
|
|
77
|
+
"3001",
|
|
78
|
+
])
|
|
79
|
+
).toEqual([
|
|
80
|
+
"node",
|
|
81
|
+
"swallowkit",
|
|
82
|
+
"dev",
|
|
83
|
+
"--seed-env",
|
|
84
|
+
"local",
|
|
85
|
+
"--mock-connectors",
|
|
86
|
+
"--port",
|
|
87
|
+
"3001",
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps non-dev commands unchanged", () => {
|
|
92
|
+
const argv = ["node", "swallowkit", "create-model", "dev", "--connector", "external"];
|
|
93
|
+
expect(normalizeDevCommandArgv(argv)).toEqual(argv);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("parses the same dev options regardless of whether they appear before or after the subcommand", async () => {
|
|
97
|
+
const expected = {
|
|
98
|
+
port: "3001",
|
|
99
|
+
functionsPort: "7072",
|
|
100
|
+
host: "127.0.0.1",
|
|
101
|
+
open: true,
|
|
102
|
+
verbose: true,
|
|
103
|
+
noFunctions: true,
|
|
104
|
+
seedEnv: "local",
|
|
105
|
+
mockConnectors: true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const optionsAfterCommand = await parseDevOptions([
|
|
109
|
+
"node",
|
|
110
|
+
"swallowkit",
|
|
111
|
+
"dev",
|
|
112
|
+
"--port",
|
|
113
|
+
"3001",
|
|
114
|
+
"--functions-port",
|
|
115
|
+
"7072",
|
|
116
|
+
"--host",
|
|
117
|
+
"127.0.0.1",
|
|
118
|
+
"--open",
|
|
119
|
+
"--verbose",
|
|
120
|
+
"--no-functions",
|
|
121
|
+
"--seed-env",
|
|
122
|
+
"local",
|
|
123
|
+
"--mock-connectors",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const optionsBeforeCommand = await parseDevOptions([
|
|
127
|
+
"node",
|
|
128
|
+
"swallowkit",
|
|
129
|
+
"--verbose",
|
|
130
|
+
"--host",
|
|
131
|
+
"127.0.0.1",
|
|
132
|
+
"--seed-env",
|
|
133
|
+
"local",
|
|
134
|
+
"--mock-connectors",
|
|
135
|
+
"--no-functions",
|
|
136
|
+
"--port",
|
|
137
|
+
"3001",
|
|
138
|
+
"--functions-port",
|
|
139
|
+
"7072",
|
|
140
|
+
"--open",
|
|
141
|
+
"dev",
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
expect(optionsAfterCommand).toMatchObject(expected);
|
|
145
|
+
expect(optionsBeforeCommand).toMatchObject(expected);
|
|
146
|
+
expect(optionsBeforeCommand).toMatchObject(optionsAfterCommand);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ModelInfo } from "../core/scaffold/model-parser";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* テスト用の基本的な ModelInfo フィクスチャ
|
|
5
|
+
*/
|
|
6
|
+
export function createBasicModelInfo(overrides?: Partial<ModelInfo>): ModelInfo {
|
|
7
|
+
return {
|
|
8
|
+
name: "Todo",
|
|
9
|
+
displayName: "Todo",
|
|
10
|
+
schemaName: "todoSchema",
|
|
11
|
+
filePath: "/models/todo.ts",
|
|
12
|
+
fields: [
|
|
13
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
14
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
15
|
+
{ name: "description", type: "string", isOptional: true, isArray: false },
|
|
16
|
+
{ name: "completed", type: "boolean", isOptional: false, isArray: false },
|
|
17
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
18
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
19
|
+
],
|
|
20
|
+
hasId: true,
|
|
21
|
+
hasCreatedAt: true,
|
|
22
|
+
hasUpdatedAt: true,
|
|
23
|
+
nestedSchemaRefs: [],
|
|
24
|
+
partitionKey: '/id',
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* RDB コネクタ (read-only) 付きの ModelInfo フィクスチャ
|
|
31
|
+
*/
|
|
32
|
+
export function createRdbConnectorModelInfo(overrides?: Partial<ModelInfo>): ModelInfo {
|
|
33
|
+
return createBasicModelInfo({
|
|
34
|
+
name: "User",
|
|
35
|
+
displayName: "User",
|
|
36
|
+
schemaName: "userSchema",
|
|
37
|
+
filePath: "/models/user.ts",
|
|
38
|
+
fields: [
|
|
39
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
40
|
+
{ name: "employeeCode", type: "string", isOptional: false, isArray: false },
|
|
41
|
+
{ name: "name", type: "string", isOptional: false, isArray: false },
|
|
42
|
+
{ name: "email", type: "string", isOptional: false, isArray: false },
|
|
43
|
+
{ name: "department", type: "string", isOptional: true, isArray: false },
|
|
44
|
+
{ name: "createdAt", type: "string", isOptional: true, isArray: false },
|
|
45
|
+
{ name: "updatedAt", type: "string", isOptional: true, isArray: false },
|
|
46
|
+
],
|
|
47
|
+
connectorConfig: {
|
|
48
|
+
connector: "mysql",
|
|
49
|
+
operations: ["getAll", "getById"],
|
|
50
|
+
table: "users",
|
|
51
|
+
idColumn: "id",
|
|
52
|
+
},
|
|
53
|
+
...overrides,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* API コネクタ (read-write) 付きの ModelInfo フィクスチャ
|
|
59
|
+
*/
|
|
60
|
+
export function createApiConnectorModelInfo(overrides?: Partial<ModelInfo>): ModelInfo {
|
|
61
|
+
return createBasicModelInfo({
|
|
62
|
+
name: "BacklogIssue",
|
|
63
|
+
displayName: "BacklogIssue",
|
|
64
|
+
schemaName: "backlogIssueSchema",
|
|
65
|
+
filePath: "/models/backlog-issue.ts",
|
|
66
|
+
fields: [
|
|
67
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
68
|
+
{ name: "projectId", type: "string", isOptional: false, isArray: false },
|
|
69
|
+
{ name: "issueKey", type: "string", isOptional: false, isArray: false },
|
|
70
|
+
{ name: "summary", type: "string", isOptional: false, isArray: false },
|
|
71
|
+
{ name: "description", type: "string", isOptional: true, isArray: false },
|
|
72
|
+
{ name: "createdAt", type: "string", isOptional: true, isArray: false },
|
|
73
|
+
{ name: "updatedAt", type: "string", isOptional: true, isArray: false },
|
|
74
|
+
],
|
|
75
|
+
connectorConfig: {
|
|
76
|
+
connector: "backlog",
|
|
77
|
+
operations: ["getAll", "getById", "create", "update"],
|
|
78
|
+
endpoints: {
|
|
79
|
+
getAll: "GET /issues",
|
|
80
|
+
getById: "GET /issues/{id}",
|
|
81
|
+
create: "POST /issues",
|
|
82
|
+
update: "PATCH /issues/{id}",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
...overrides,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 外部キーを含む ModelInfo フィクスチャ
|
|
91
|
+
*/
|
|
92
|
+
export function createModelInfoWithForeignKey(): ModelInfo {
|
|
93
|
+
return createBasicModelInfo({
|
|
94
|
+
name: "Task",
|
|
95
|
+
displayName: "Task",
|
|
96
|
+
schemaName: "taskSchema",
|
|
97
|
+
filePath: "/models/task.ts",
|
|
98
|
+
fields: [
|
|
99
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
100
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
101
|
+
{
|
|
102
|
+
name: "categoryId",
|
|
103
|
+
type: "string",
|
|
104
|
+
isOptional: false,
|
|
105
|
+
isArray: false,
|
|
106
|
+
isForeignKey: true,
|
|
107
|
+
referencedModel: "Category",
|
|
108
|
+
},
|
|
109
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
110
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* enum フィールドを含む ModelInfo フィクスチャ
|
|
117
|
+
*/
|
|
118
|
+
export function createModelInfoWithEnum(): ModelInfo {
|
|
119
|
+
return createBasicModelInfo({
|
|
120
|
+
name: "Issue",
|
|
121
|
+
displayName: "Issue",
|
|
122
|
+
schemaName: "issueSchema",
|
|
123
|
+
filePath: "/models/issue.ts",
|
|
124
|
+
fields: [
|
|
125
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
126
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
127
|
+
{
|
|
128
|
+
name: "status",
|
|
129
|
+
type: "string",
|
|
130
|
+
isOptional: false,
|
|
131
|
+
isArray: false,
|
|
132
|
+
enumValues: ["open", "in_progress", "closed"],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "priority",
|
|
136
|
+
type: "number",
|
|
137
|
+
isOptional: true,
|
|
138
|
+
isArray: false,
|
|
139
|
+
},
|
|
140
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
141
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateCSharpAzureFunctionsCRUD,
|
|
3
|
+
generateCompactAzureFunctionsCRUD,
|
|
4
|
+
generatePythonAzureFunctionsCRUD,
|
|
5
|
+
} from "../core/scaffold/functions-generator";
|
|
6
|
+
import { createBasicModelInfo, createModelInfoWithEnum } from "./fixtures";
|
|
7
|
+
|
|
8
|
+
describe("generateCompactAzureFunctionsCRUD", () => {
|
|
9
|
+
it("generates correct CRUD code for a basic model", () => {
|
|
10
|
+
const model = createBasicModelInfo();
|
|
11
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
12
|
+
expect(code).toMatchSnapshot();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("generates correct CRUD code for a model with enum fields", () => {
|
|
16
|
+
const model = createModelInfoWithEnum();
|
|
17
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
18
|
+
expect(code).toMatchSnapshot();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("imports the correct schema from shared package", () => {
|
|
22
|
+
const model = createBasicModelInfo();
|
|
23
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
24
|
+
expect(code).toContain("import { todoSchema } from '@myapp/shared'");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("uses correct container name (PascalCase + s)", () => {
|
|
28
|
+
const model = createBasicModelInfo();
|
|
29
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
30
|
+
expect(code).toContain("const containerName = 'Todos'");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("registers correct route names (camelCase)", () => {
|
|
34
|
+
const model = createBasicModelInfo();
|
|
35
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
36
|
+
expect(code).toContain("'todo-get-all'");
|
|
37
|
+
expect(code).toContain("'todo-get-by-id'");
|
|
38
|
+
expect(code).toContain("'todo-create'");
|
|
39
|
+
expect(code).toContain("'todo-update'");
|
|
40
|
+
expect(code).toContain("'todo-delete'");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("generates all CRUD HTTP methods", () => {
|
|
44
|
+
const model = createBasicModelInfo();
|
|
45
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
46
|
+
expect(code).toContain("methods: ['GET']");
|
|
47
|
+
expect(code).toContain("methods: ['POST']");
|
|
48
|
+
expect(code).toContain("methods: ['PUT']");
|
|
49
|
+
expect(code).toContain("methods: ['DELETE']");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("uses correct route patterns", () => {
|
|
53
|
+
const model = createBasicModelInfo();
|
|
54
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
55
|
+
expect(code).toContain("route: 'todo'");
|
|
56
|
+
expect(code).toContain("route: 'todo/{id}'");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("handles multi-word model names correctly", () => {
|
|
60
|
+
const model = createBasicModelInfo({
|
|
61
|
+
name: "TodoItem",
|
|
62
|
+
schemaName: "todoItemSchema",
|
|
63
|
+
});
|
|
64
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
65
|
+
expect(code).toContain("const containerName = 'TodoItems'");
|
|
66
|
+
expect(code).toContain("route: 'todoItem'");
|
|
67
|
+
expect(code).toContain("'todoItem-get-all'");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("generates C# Cosmos-backed CRUD handlers", () => {
|
|
71
|
+
const model = createBasicModelInfo();
|
|
72
|
+
const code = generateCSharpAzureFunctionsCRUD(model);
|
|
73
|
+
expect(code).toContain("public sealed class TodoFunctions");
|
|
74
|
+
expect(code).toContain('[Function("todoGetAll")]');
|
|
75
|
+
expect(code).toContain('Route = "todo/{id}"');
|
|
76
|
+
expect(code).toContain("CreateCosmosClient()");
|
|
77
|
+
expect(code).toContain('new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway }');
|
|
78
|
+
expect(code).toContain('endpoint.Contains("localhost:8081", StringComparison.OrdinalIgnoreCase)');
|
|
79
|
+
expect(code).toContain("container.ReadItemStreamAsync");
|
|
80
|
+
expect(code).toContain("JsonNode.Parse(document.RootElement.GetRawText())?.AsObject()");
|
|
81
|
+
expect(code).toContain("container.CreateItemStreamAsync");
|
|
82
|
+
expect(code).toContain("container.ReplaceItemStreamAsync");
|
|
83
|
+
expect(code).toContain("payload.ToJsonString()");
|
|
84
|
+
expect(code).toContain("container.DeleteItemAsync<JsonObject>");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("generates Python Cosmos-backed CRUD handlers", () => {
|
|
88
|
+
const model = createBasicModelInfo();
|
|
89
|
+
const generated = generatePythonAzureFunctionsCRUD(model);
|
|
90
|
+
expect(generated.registration).toContain("from blueprints.todo import bp as todo_bp");
|
|
91
|
+
expect(generated.registration).toContain("app.register_blueprint(todo_bp)");
|
|
92
|
+
expect(generated.blueprint).toContain('@bp.route(route="todo", methods=["GET"])');
|
|
93
|
+
expect(generated.blueprint).toContain("def todo_create");
|
|
94
|
+
expect(generated.blueprint).toContain("from azure.cosmos import CosmosClient, exceptions");
|
|
95
|
+
expect(generated.blueprint).toContain("container.query_items");
|
|
96
|
+
expect(generated.blueprint).toContain("container.read_item");
|
|
97
|
+
expect(generated.blueprint).toContain("container.create_item");
|
|
98
|
+
expect(generated.blueprint).toContain("container.replace_item");
|
|
99
|
+
expect(generated.blueprint).toContain("container.delete_item");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// --- Custom Partition Key Tests ---
|
|
103
|
+
|
|
104
|
+
describe("custom partition key (TS)", () => {
|
|
105
|
+
it("uses input binding when partitionKey is /id (default)", () => {
|
|
106
|
+
const model = createBasicModelInfo({ partitionKey: "/id" });
|
|
107
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
108
|
+
expect(code).toContain("partitionKey: '{id}'");
|
|
109
|
+
expect(code).toContain("container.item(id, id).delete()");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("uses SDK direct call when partitionKey is not /id", () => {
|
|
113
|
+
const model = createBasicModelInfo({
|
|
114
|
+
partitionKey: "/tenantId",
|
|
115
|
+
fields: [
|
|
116
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
117
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
118
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
119
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
120
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
124
|
+
|
|
125
|
+
// Should NOT have input binding with partitionKey
|
|
126
|
+
expect(code).not.toContain("partitionKey: '{id}'");
|
|
127
|
+
// getById should use SDK query
|
|
128
|
+
expect(code).toContain("custom partition key");
|
|
129
|
+
expect(code).toContain("SELECT * FROM c WHERE c.id = @id");
|
|
130
|
+
// delete should read doc first to get PK value
|
|
131
|
+
expect(code).toContain("resources[0].tenantId");
|
|
132
|
+
expect(code).toContain("container.item(id, pkValue).delete()");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("generates snapshot for custom partition key TS", () => {
|
|
136
|
+
const model = createBasicModelInfo({
|
|
137
|
+
partitionKey: "/tenantId",
|
|
138
|
+
fields: [
|
|
139
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
140
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
141
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
142
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
143
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
147
|
+
expect(code).toMatchSnapshot();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("custom partition key (C#)", () => {
|
|
152
|
+
it("uses ReadItemStreamAsync when partitionKey is /id", () => {
|
|
153
|
+
const model = createBasicModelInfo({ partitionKey: "/id" });
|
|
154
|
+
const code = generateCSharpAzureFunctionsCRUD(model);
|
|
155
|
+
expect(code).toContain("ReadItemStreamAsync(id, new PartitionKey(id))");
|
|
156
|
+
expect(code).toContain("DeleteItemAsync<JsonObject>(id, new PartitionKey(id))");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("uses query when partitionKey is not /id", () => {
|
|
160
|
+
const model = createBasicModelInfo({
|
|
161
|
+
partitionKey: "/tenantId",
|
|
162
|
+
fields: [
|
|
163
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
164
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
165
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
166
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
167
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
const code = generateCSharpAzureFunctionsCRUD(model);
|
|
171
|
+
// ReadCosmosItemAsync should use query instead of point read
|
|
172
|
+
expect(code).toContain("GetItemQueryStreamIterator(query)");
|
|
173
|
+
expect(code).not.toContain("ReadItemStreamAsync(id, new PartitionKey(id))");
|
|
174
|
+
// Delete should read doc first for PK value
|
|
175
|
+
expect(code).toContain('existing["tenantId"]');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("generates snapshot for custom partition key C#", () => {
|
|
179
|
+
const model = createBasicModelInfo({
|
|
180
|
+
partitionKey: "/tenantId",
|
|
181
|
+
fields: [
|
|
182
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
183
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
184
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
185
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
186
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
const code = generateCSharpAzureFunctionsCRUD(model);
|
|
190
|
+
expect(code).toMatchSnapshot();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("custom partition key (Python)", () => {
|
|
195
|
+
it("uses read_item with partition_key=item_id when partitionKey is /id", () => {
|
|
196
|
+
const model = createBasicModelInfo({ partitionKey: "/id" });
|
|
197
|
+
const generated = generatePythonAzureFunctionsCRUD(model);
|
|
198
|
+
expect(generated.blueprint).toContain("partition_key=item_id");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("uses cross-partition query when partitionKey is not /id", () => {
|
|
202
|
+
const model = createBasicModelInfo({
|
|
203
|
+
partitionKey: "/tenantId",
|
|
204
|
+
fields: [
|
|
205
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
206
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
207
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
208
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
209
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
const generated = generatePythonAzureFunctionsCRUD(model);
|
|
213
|
+
// Should not use direct read_item with partition_key=item_id
|
|
214
|
+
expect(generated.blueprint).not.toContain("partition_key=item_id");
|
|
215
|
+
// Should use cross-partition query
|
|
216
|
+
expect(generated.blueprint).toContain("enable_cross_partition_query=True");
|
|
217
|
+
expect(generated.blueprint).toContain('SELECT * FROM c WHERE c.id = @id');
|
|
218
|
+
// Delete should get PK value from document
|
|
219
|
+
expect(generated.blueprint).toContain('.get("tenantId")');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("generates snapshot for custom partition key Python", () => {
|
|
223
|
+
const model = createBasicModelInfo({
|
|
224
|
+
partitionKey: "/tenantId",
|
|
225
|
+
fields: [
|
|
226
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
227
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
228
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
229
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
230
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
const generated = generatePythonAzureFunctionsCRUD(model);
|
|
234
|
+
expect(generated.blueprint).toMatchSnapshot();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|