swallowkit 2.0.0-alpha.3 → 2.0.0-alpha.4
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/dist/cli/commands/configure-mcp.d.ts +12 -0
- package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
- package/dist/cli/commands/configure-mcp.js +42 -0
- package/dist/cli/commands/configure-mcp.js.map +1 -0
- package/dist/cli/commands/init.d.ts +4 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +16 -4
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/migrations.d.ts.map +1 -1
- package/dist/cli/commands/migrations.js +25 -0
- package/dist/cli/commands/migrations.js.map +1 -1
- package/dist/cli/commands/provision.d.ts +2 -0
- package/dist/cli/commands/provision.d.ts.map +1 -1
- package/dist/cli/commands/provision.js +45 -16
- package/dist/cli/commands/provision.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +4 -0
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +40 -2
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +27 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/core/operations/model-migration.d.ts +22 -0
- package/dist/core/operations/model-migration.d.ts.map +1 -0
- package/dist/core/operations/model-migration.js +36 -0
- package/dist/core/operations/model-migration.js.map +1 -0
- package/dist/core/operations/provision-operations.d.ts +19 -1
- package/dist/core/operations/provision-operations.d.ts.map +1 -1
- package/dist/core/operations/provision-operations.js +62 -35
- package/dist/core/operations/provision-operations.js.map +1 -1
- package/dist/core/operations/scaffold-apply.d.ts +1 -0
- package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
- package/dist/core/operations/scaffold-apply.js +1 -0
- package/dist/core/operations/scaffold-apply.js.map +1 -1
- package/dist/core/operations/scaffold-machine.d.ts +1 -0
- package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
- package/dist/core/operations/scaffold-machine.js.map +1 -1
- package/dist/core/operations/scaffold-plan.d.ts +2 -0
- package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
- package/dist/core/operations/scaffold-plan.js +3 -1
- package/dist/core/operations/scaffold-plan.js.map +1 -1
- package/dist/core/project/boundaries.d.ts +5 -2
- package/dist/core/project/boundaries.d.ts.map +1 -1
- package/dist/core/project/boundaries.js +49 -18
- package/dist/core/project/boundaries.js.map +1 -1
- package/dist/core/project/capabilities.d.ts +1 -0
- package/dist/core/project/capabilities.d.ts.map +1 -1
- package/dist/core/project/capabilities.js +20 -2
- package/dist/core/project/capabilities.js.map +1 -1
- package/dist/core/project/mcp-config.d.ts +26 -0
- package/dist/core/project/mcp-config.d.ts.map +1 -0
- package/dist/core/project/mcp-config.js +117 -0
- package/dist/core/project/mcp-config.js.map +1 -0
- package/dist/core/project/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +9 -1
- package/dist/core/project/workflows.js.map +1 -1
- package/dist/core/scaffold/model-parser.d.ts +23 -0
- package/dist/core/scaffold/model-parser.d.ts.map +1 -1
- package/dist/core/scaffold/model-parser.js +407 -137
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/dist/core/scaffold/native-schema-generator.d.ts +11 -0
- package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
- package/dist/core/scaffold/native-schema-generator.js +273 -75
- package/dist/core/scaffold/native-schema-generator.js.map +1 -1
- package/dist/core/scaffold/openapi-generator.d.ts +5 -0
- package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
- package/dist/core/scaffold/openapi-generator.js +53 -3
- package/dist/core/scaffold/openapi-generator.js.map +1 -1
- package/dist/core/scaffold/ui-generator.d.ts +0 -3
- package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
- package/dist/core/scaffold/ui-generator.js +68 -7
- package/dist/core/scaffold/ui-generator.js.map +1 -1
- package/dist/core/verify/index.d.ts.map +1 -1
- package/dist/core/verify/index.js +24 -1
- package/dist/core/verify/index.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +39 -15
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts +3 -2
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +137 -13
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/version-selection.d.ts +55 -0
- package/dist/mcp/version-selection.d.ts.map +1 -0
- package/dist/mcp/version-selection.js +152 -0
- package/dist/mcp/version-selection.js.map +1 -0
- package/dist/utils/azure-cli.d.ts +44 -0
- package/dist/utils/azure-cli.d.ts.map +1 -0
- package/dist/utils/azure-cli.js +164 -0
- package/dist/utils/azure-cli.js.map +1 -0
- package/dist/utils/semver.d.ts +14 -0
- package/dist/utils/semver.d.ts.map +1 -0
- package/dist/utils/semver.js +61 -0
- package/dist/utils/semver.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
- package/src/__tests__/agent-loop-phase34.test.ts +29 -0
- package/src/__tests__/azure-cli.test.ts +199 -0
- package/src/__tests__/boundaries.test.ts +42 -0
- package/src/__tests__/csharp-schema.test.ts +261 -0
- package/src/__tests__/mcp-version.test.ts +262 -0
- package/src/__tests__/mcp.test.ts +49 -3
- package/src/__tests__/plan-apply.test.ts +59 -0
- package/src/__tests__/schema-analysis.test.ts +329 -0
- package/src/cli/commands/configure-mcp.ts +54 -0
- package/src/cli/commands/init.ts +19 -4
- package/src/cli/commands/migrations.ts +22 -0
- package/src/cli/commands/provision.ts +53 -17
- package/src/cli/commands/scaffold.ts +40 -2
- package/src/cli/index.ts +26 -3
- package/src/core/operations/model-migration.ts +52 -0
- package/src/core/operations/provision-operations.ts +86 -42
- package/src/core/operations/scaffold-apply.ts +2 -0
- package/src/core/operations/scaffold-machine.ts +1 -0
- package/src/core/operations/scaffold-plan.ts +5 -1
- package/src/core/project/agent-templates/AGENTS.md +39 -3
- package/src/core/project/agent-templates/CLAUDE.md +3 -1
- package/src/core/project/agent-templates/copilot-instructions.md +3 -1
- package/src/core/project/boundaries.ts +60 -18
- package/src/core/project/capabilities.ts +22 -2
- package/src/core/project/mcp-config.ts +84 -0
- package/src/core/project/workflows.ts +9 -1
- package/src/core/scaffold/model-parser.ts +413 -129
- package/src/core/scaffold/native-schema-generator.ts +297 -78
- package/src/core/scaffold/openapi-generator.ts +63 -5
- package/src/core/scaffold/ui-generator.ts +70 -8
- package/src/core/verify/index.ts +23 -1
- package/src/machine/index.ts +42 -18
- package/src/mcp/index.ts +156 -14
- package/src/mcp/version-selection.ts +163 -0
- package/src/utils/azure-cli.ts +154 -0
- package/src/utils/semver.ts +59 -0
|
@@ -17,6 +17,7 @@ import { inspectInfra } from "../core/project/infra";
|
|
|
17
17
|
import { loadCustomVerifyChecks, compactVerifyResult, runVerify, VerifyResult } from "../core/verify";
|
|
18
18
|
import { buildAgentSkills, buildWorkflowDocs, writeAgentSkills, writeWorkflowDocs } from "../core/project/workflows";
|
|
19
19
|
import { applyProvisionOperation, planProvisionOperation } from "../core/operations/provision-operations";
|
|
20
|
+
import { resetAzCliInvocationCache, setAzCliInvocationForTesting } from "../utils/azure-cli";
|
|
20
21
|
import { createCustomMigration, initializeLegacyMigrations } from "../core/operations/migration-generator";
|
|
21
22
|
import { createMigrationManifest, encodeMigrationState, getMigrationTagKey, saveMigrationManifest } from "../core/project/migrations";
|
|
22
23
|
|
|
@@ -126,9 +127,12 @@ describe("agent loop phase 3/4", () => {
|
|
|
126
127
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-p34-"));
|
|
127
128
|
process.chdir(tempDir);
|
|
128
129
|
(childProcess.spawnSync as unknown as any).mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
130
|
+
// Platform-independent az invocation; the Windows wrapper resolution is covered in azure-cli.test.ts.
|
|
131
|
+
setAzCliInvocationForTesting({ command: "az", prefixArgs: [], source: "az" });
|
|
129
132
|
});
|
|
130
133
|
|
|
131
134
|
afterEach(() => {
|
|
135
|
+
resetAzCliInvocationCache();
|
|
132
136
|
process.chdir(originalCwd);
|
|
133
137
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
134
138
|
jest.clearAllMocks();
|
|
@@ -269,6 +273,7 @@ describe("agent loop phase 3/4", () => {
|
|
|
269
273
|
createProjectFixture(tempDir);
|
|
270
274
|
writeFile(path.join(tempDir, "infra", "main.bicep"), SAMPLE_MAIN_BICEP);
|
|
271
275
|
writeFile(path.join(tempDir, "infra", "main.parameters.json"), "{}\n");
|
|
276
|
+
setAzCliInvocationForTesting(null);
|
|
272
277
|
|
|
273
278
|
const plan = await runMachine([
|
|
274
279
|
"node", "swallowkit", "machine", "plan", "provision",
|
|
@@ -508,6 +513,30 @@ describe("agent loop phase 3/4", () => {
|
|
|
508
513
|
);
|
|
509
514
|
});
|
|
510
515
|
|
|
516
|
+
it.each<[string, string[]]>([
|
|
517
|
+
["pnpm", ["exec", "next", "typegen"]],
|
|
518
|
+
["npm", ["exec", "--", "next", "typegen"]],
|
|
519
|
+
])("generates Next.js route types with %s before typechecking", async (packageManager, typegenArgs) => {
|
|
520
|
+
writeFile(
|
|
521
|
+
path.join(tempDir, "package.json"),
|
|
522
|
+
JSON.stringify({ name: "sample-app", devEngines: { packageManager: { name: packageManager, version: ">=10", onFail: "error" } } })
|
|
523
|
+
);
|
|
524
|
+
writeFile(path.join(tempDir, "tsconfig.json"), JSON.stringify({ compilerOptions: { noEmit: true } }));
|
|
525
|
+
writeFile(path.join(tempDir, "next.config.ts"), "export default {};\n");
|
|
526
|
+
writeFile(path.join(tempDir, "node_modules", "next", "package.json"), JSON.stringify({ name: "next", version: "16.0.0" }));
|
|
527
|
+
const spawnSyncMock = childProcess.spawnSync as unknown as ReturnType<typeof jest.fn>;
|
|
528
|
+
spawnSyncMock.mockReset();
|
|
529
|
+
spawnSyncMock
|
|
530
|
+
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "typegen failed" })
|
|
531
|
+
.mockReturnValueOnce({ status: 2, stdout: "app/layout.tsx: Cannot find name 'LayoutProps'.", stderr: "" });
|
|
532
|
+
|
|
533
|
+
const result = await runVerify(["typecheck"], tempDir);
|
|
534
|
+
|
|
535
|
+
expect(spawnSyncMock.mock.calls[0].slice(0, 2)).toEqual([packageManager, typegenArgs]);
|
|
536
|
+
expect(result.checks[0].status).toBe("fail");
|
|
537
|
+
expect(result.checks[0].evidence.logTail?.[0]).toMatch(/^next typegen did not complete \(exit 1\)/);
|
|
538
|
+
});
|
|
539
|
+
|
|
511
540
|
it("uses the existing typecheck script when present", async () => {
|
|
512
541
|
writeFile(
|
|
513
542
|
path.join(tempDir, "package.json"),
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import * as childProcess from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import {
|
|
6
|
+
buildBatchCommandLine,
|
|
7
|
+
checkAzCliAvailability,
|
|
8
|
+
resetAzCliInvocationCache,
|
|
9
|
+
resolveAzCliInvocation,
|
|
10
|
+
runAzCli,
|
|
11
|
+
setAzCliInvocationForTesting,
|
|
12
|
+
} from "../utils/azure-cli";
|
|
13
|
+
import { INTERACTIVE_PROVISION_RETRY_HINT, executeProvisionPlan, planProvisionOperation } from "../core/operations/provision-operations";
|
|
14
|
+
import { describeProvisionAction } from "../cli/commands/provision";
|
|
15
|
+
import { createMigrationManifest, saveMigrationManifest } from "../core/project/migrations";
|
|
16
|
+
import { formatCliError } from "../cli";
|
|
17
|
+
import { MachineCommandError } from "../machine/errors";
|
|
18
|
+
|
|
19
|
+
jest.mock("child_process", () => {
|
|
20
|
+
const actual = jest.requireActual<typeof import("child_process")>("child_process");
|
|
21
|
+
return { ...actual, spawnSync: jest.fn(actual.spawnSync) };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const spawnSyncMock = jest.mocked(childProcess.spawnSync) as unknown as ReturnType<typeof jest.fn>;
|
|
25
|
+
|
|
26
|
+
describe("Azure CLI resolution on Windows", () => {
|
|
27
|
+
let root: string;
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-az-"));
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
32
|
+
|
|
33
|
+
const touch = (file: string) => {
|
|
34
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
35
|
+
fs.writeFileSync(file, "");
|
|
36
|
+
return file;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
it("runs the MSI install's bundled Python the same way az.cmd does", () => {
|
|
40
|
+
const wrapper = touch(path.join(root, "CLI2", "wbin", "az.cmd"));
|
|
41
|
+
touch(path.join(root, "CLI2", "wbin", "az"));
|
|
42
|
+
const python = touch(path.join(root, "CLI2", "python.exe"));
|
|
43
|
+
const invocation = resolveAzCliInvocation({ PATH: `${path.join(root, "missing")};${path.dirname(wrapper)}`, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, "win32");
|
|
44
|
+
expect(invocation).toEqual({ command: python, prefixArgs: ["-IBm", "azure.cli"], env: { AZ_INSTALLER: "MSI" }, source: wrapper });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("uses an executable directly and other batch wrappers through cmd.exe", () => {
|
|
48
|
+
const exe = touch(path.join(root, "exe", "az.exe"));
|
|
49
|
+
expect(resolveAzCliInvocation({ PATH: path.dirname(exe) }, "win32")).toEqual({ command: exe, prefixArgs: [], source: exe });
|
|
50
|
+
const bat = touch(path.join(root, "venv", "Scripts", "az.bat"));
|
|
51
|
+
expect(resolveAzCliInvocation({ PATH: path.dirname(bat), ComSpec: "C:\\Windows\\system32\\cmd.exe" }, "win32"))
|
|
52
|
+
.toEqual({ command: "C:\\Windows\\system32\\cmd.exe", prefixArgs: [], batchScript: bat, source: bat });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reports not found when PATH has no az, and uses az directly elsewhere", () => {
|
|
56
|
+
expect(resolveAzCliInvocation({ PATH: root }, "win32")).toBeNull();
|
|
57
|
+
expect(resolveAzCliInvocation({ PATH: "/usr/bin" }, "linux")).toEqual({ command: "az", prefixArgs: [], source: "az" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("quotes batch arguments and refuses values cmd.exe would reinterpret", () => {
|
|
61
|
+
expect(buildBatchCommandLine("C:\\Program Files\\az.cmd", ["deployment", "--template-file", "C:\\my app\\main.bicep", "location=japaneast"]))
|
|
62
|
+
.toBe('""C:\\Program Files\\az.cmd" "deployment" "--template-file" "C:\\my app\\main.bicep" "location=japaneast""');
|
|
63
|
+
expect(() => buildBatchCommandLine("az.cmd", ["100%"])).toThrow(/cannot be passed safely/);
|
|
64
|
+
expect(() => buildBatchCommandLine("az.cmd", ['a"b'])).toThrow(/cannot be passed safely/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("Azure CLI invocation and availability", () => {
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
resetAzCliInvocationCache();
|
|
71
|
+
spawnSyncMock.mockReset();
|
|
72
|
+
spawnSyncMock.mockImplementation(jest.requireActual<typeof import("child_process")>("child_process").spawnSync);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("never uses a shell and prepends wrapper arguments", () => {
|
|
76
|
+
spawnSyncMock.mockReturnValue({ status: 0, stdout: "ok", stderr: "" });
|
|
77
|
+
const invocation = { command: "C:\\CLI2\\python.exe", prefixArgs: ["-IBm", "azure.cli"], env: { AZ_INSTALLER: "MSI" }, source: "az.cmd" };
|
|
78
|
+
expect(runAzCli(["group", "exists", "--name", "rg"], { invocation })).toMatchObject({ status: 0, stdout: "ok" });
|
|
79
|
+
const [command, args, options] = spawnSyncMock.mock.calls[0];
|
|
80
|
+
expect(command).toBe("C:\\CLI2\\python.exe");
|
|
81
|
+
expect(args).toEqual(["-IBm", "azure.cli", "group", "exists", "--name", "rg"]);
|
|
82
|
+
expect(options).toMatchObject({ shell: false, env: expect.objectContaining({ AZ_INSTALLER: "MSI" }) });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("runs batch wrappers through cmd.exe with verbatim quoted arguments", () => {
|
|
86
|
+
spawnSyncMock.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
|
87
|
+
runAzCli(["--version"], { invocation: { command: "cmd.exe", prefixArgs: [], batchScript: "C:\\az.bat", source: "C:\\az.bat" } });
|
|
88
|
+
const [command, args, options] = spawnSyncMock.mock.calls[0];
|
|
89
|
+
expect(command).toBe("cmd.exe");
|
|
90
|
+
expect(args).toEqual(["/d", "/s", "/v:off", "/c", '""C:\\az.bat" "--version""']);
|
|
91
|
+
expect(options).toMatchObject({ shell: false, windowsVerbatimArguments: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("distinguishes a missing CLI from one that cannot start", () => {
|
|
95
|
+
setAzCliInvocationForTesting(null);
|
|
96
|
+
expect(checkAzCliAvailability()).toEqual({ available: false, reason: "not-found", detail: "az was not found on PATH." });
|
|
97
|
+
|
|
98
|
+
setAzCliInvocationForTesting({ command: "az", prefixArgs: [], source: "az" });
|
|
99
|
+
spawnSyncMock.mockReturnValue({ status: null, stdout: "", stderr: "", error: Object.assign(new Error("spawn az ENOENT"), { code: "ENOENT" }) });
|
|
100
|
+
expect(checkAzCliAvailability()).toMatchObject({ available: false, reason: "not-found" });
|
|
101
|
+
|
|
102
|
+
spawnSyncMock.mockReturnValue({ status: 1, stdout: "", stderr: "ModuleNotFoundError: azure.cli" });
|
|
103
|
+
expect(checkAzCliAvailability()).toMatchObject({ available: false, reason: "not-runnable", detail: expect.stringContaining("ModuleNotFoundError") });
|
|
104
|
+
|
|
105
|
+
spawnSyncMock.mockReturnValue({ status: 0, stdout: "azure-cli 2.70.0", stderr: "" });
|
|
106
|
+
expect(checkAzCliAvailability()).toMatchObject({ available: true });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("provision planning with Azure CLI state", () => {
|
|
111
|
+
const originalCwd = process.cwd();
|
|
112
|
+
let project: string;
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
project = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-provision-az-"));
|
|
115
|
+
fs.mkdirSync(path.join(project, "infra"), { recursive: true });
|
|
116
|
+
fs.writeFileSync(path.join(project, "infra", "main.bicep"), "targetScope = 'resourceGroup'\n");
|
|
117
|
+
fs.writeFileSync(path.join(project, "infra", "main.parameters.json"), JSON.stringify({ parameters: { projectName: { value: "sample" } } }));
|
|
118
|
+
saveMigrationManifest(createMigrationManifest(project, { projectId: "project" }), project);
|
|
119
|
+
process.chdir(project);
|
|
120
|
+
setAzCliInvocationForTesting({ command: "az", prefixArgs: [], source: "az" });
|
|
121
|
+
});
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
process.chdir(originalCwd);
|
|
124
|
+
resetAzCliInvocationCache();
|
|
125
|
+
spawnSyncMock.mockReset();
|
|
126
|
+
spawnSyncMock.mockImplementation(jest.requireActual<typeof import("child_process")>("child_process").spawnSync);
|
|
127
|
+
fs.rmSync(project, { recursive: true, force: true });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const options = { resourceGroup: "rg-sample", location: "japaneast", swaLocation: "eastasia" };
|
|
131
|
+
|
|
132
|
+
it("reads Azure state through the resolved CLI and plans a bootstrap", async () => {
|
|
133
|
+
spawnSyncMock.mockImplementation((_command: string, args: string[]) => {
|
|
134
|
+
if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
|
|
135
|
+
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: "false\n", stderr: "" };
|
|
136
|
+
return { status: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
|
|
137
|
+
});
|
|
138
|
+
const plan = await planProvisionOperation(options);
|
|
139
|
+
expect(plan.azCliAvailable).toBe(true);
|
|
140
|
+
expect(plan.mode).toBe("bootstrap");
|
|
141
|
+
expect(plan.warnings.filter((warning) => warning.startsWith("az-"))).toEqual([]);
|
|
142
|
+
expect(spawnSyncMock.mock.calls.every((call: unknown[]) => (call[2] as { shell?: boolean }).shell === false)).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const plansDir = () => path.join(project, ".swallowkit", "state", "plans");
|
|
146
|
+
const savedPlans = () => (fs.existsSync(plansDir()) ? fs.readdirSync(plansDir()) : []);
|
|
147
|
+
|
|
148
|
+
it("keeps the interactive preview in memory and re-checks Azure before running it", async () => {
|
|
149
|
+
let groupExists = false;
|
|
150
|
+
spawnSyncMock.mockImplementation((_command: string, args: string[]) => {
|
|
151
|
+
if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
|
|
152
|
+
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: `${groupExists}\n`, stderr: "" };
|
|
153
|
+
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/test/resourceGroups/rg-sample", tags: {} }), stderr: "" };
|
|
154
|
+
return { status: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
|
|
155
|
+
});
|
|
156
|
+
const preview = await planProvisionOperation(options, { persist: false });
|
|
157
|
+
expect(savedPlans()).toEqual([]);
|
|
158
|
+
expect(describeProvisionAction(preview)).toBe(
|
|
159
|
+
"Resource group rg-sample does not exist yet. It will be created in japaneast and the infrastructure in infra/main.bicep will be deployed."
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
groupExists = true;
|
|
163
|
+
await expect(executeProvisionPlan(preview, { approve: true, retryHint: INTERACTIVE_PROVISION_RETRY_HINT })).rejects.toMatchObject({
|
|
164
|
+
code: "stale-azure-state",
|
|
165
|
+
message: 'The resource group was created after the preview was computed. Run "swallowkit provision" again to review the current state.',
|
|
166
|
+
});
|
|
167
|
+
expect(spawnSyncMock.mock.calls.some((call: unknown[]) => (call[1] as string[])[0] === "group" && (call[1] as string[])[1] === "create")).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("still saves plans for the machine plan/apply workflow", async () => {
|
|
171
|
+
spawnSyncMock.mockImplementation((_command: string, args: string[]) => args[0] === "--version"
|
|
172
|
+
? { status: 0, stdout: "azure-cli", stderr: "" }
|
|
173
|
+
: { status: 0, stdout: "false\n", stderr: "" });
|
|
174
|
+
const plan = await planProvisionOperation(options);
|
|
175
|
+
expect(savedPlans().some((file) => file.includes(plan.planId))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("asks for az login instead of reporting a missing CLI", async () => {
|
|
179
|
+
spawnSyncMock.mockImplementation((_command: string, args: string[]) => args[0] === "--version"
|
|
180
|
+
? { status: 0, stdout: "azure-cli", stderr: "" }
|
|
181
|
+
: { status: 1, stdout: "", stderr: "ERROR: Please run 'az login' to setup account." });
|
|
182
|
+
await expect(planProvisionOperation(options)).rejects.toMatchObject({ code: "az-not-logged-in", status: "requires-human" });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("reports an installed but broken CLI accurately", async () => {
|
|
186
|
+
spawnSyncMock.mockReturnValue({ status: 1, stdout: "", stderr: "Failed to load python executable." });
|
|
187
|
+
const plan = await planProvisionOperation(options);
|
|
188
|
+
expect(plan.mode).toBe("offline");
|
|
189
|
+
expect(plan.warnings.some((warning) => warning.startsWith("az-cli-not-runnable:") && warning.includes("Failed to load python executable."))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("regular CLI error output", () => {
|
|
194
|
+
it("prints machine errors without a stack trace", () => {
|
|
195
|
+
const text = formatCliError(new MachineCommandError("online-plan-required", "Cannot apply.", { nextAction: "Run az login." }, "blocked"));
|
|
196
|
+
expect(text).toBe("❌ Cannot apply.\n (code: online-plan-required)\n Next: Run az login.");
|
|
197
|
+
expect(formatCliError(new Error("plain failure"))).toBe("❌ plain failure");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { inspectBoundaries } from "../core/project/boundaries";
|
|
5
|
+
import { buildSwallowKitConfigSource } from "../cli/commands/init";
|
|
6
|
+
import type { BackendLanguage } from "../types";
|
|
7
|
+
|
|
8
|
+
describe("inspect boundaries convention rules", () => {
|
|
9
|
+
let project: string;
|
|
10
|
+
const originalCwd = process.cwd();
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
project = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-boundaries-"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
process.chdir(originalCwd);
|
|
18
|
+
fs.rmSync(project, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each<BackendLanguage>(["csharp", "python", "typescript"])("matches the %s backend", (language) => {
|
|
22
|
+
const contract = inspectBoundaries(project, language);
|
|
23
|
+
expect(contract.backendLanguage).toBe(language);
|
|
24
|
+
expect(contract.conventionRules).toMatchSnapshot();
|
|
25
|
+
expect(contract.conventionRules.some((rule) => rule.pattern === "functions/function_app.py")).toBe(language === "python");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("resolves the backend language from swallowkit.config", () => {
|
|
29
|
+
fs.writeFileSync(path.join(project, "swallowkit.config.js"), buildSwallowKitConfigSource("csharp", "react"));
|
|
30
|
+
process.chdir(project);
|
|
31
|
+
const contract = inspectBoundaries(project);
|
|
32
|
+
expect(contract.backendLanguage).toBe("csharp");
|
|
33
|
+
expect(contract.conventionRules.map((rule) => rule.pattern)).not.toContain("functions/function_app.py");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("orders specific infrastructure rules before the baseline rule", () => {
|
|
37
|
+
const patterns = inspectBoundaries(project, "typescript").conventionRules.map((rule) => rule.pattern);
|
|
38
|
+
expect(patterns.indexOf("infra/migrations/manifest.json")).toBeLessThan(patterns.indexOf("infra/migrations/"));
|
|
39
|
+
expect(patterns.indexOf("infra/migrations/")).toBeLessThan(patterns.indexOf("infra/"));
|
|
40
|
+
expect(patterns[patterns.length - 1]).toBe("*");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import * as z from "zod/v4";
|
|
6
|
+
import { FileOperationSession } from "../core/operations/file-session";
|
|
7
|
+
import { parseModelFile } from "../core/scaffold/model-parser";
|
|
8
|
+
import { generateLanguageSchemaArtifacts } from "../core/scaffold/native-schema-generator";
|
|
9
|
+
import { generateOpenApiDocument } from "../core/scaffold/openapi-generator";
|
|
10
|
+
|
|
11
|
+
const MODEL_SOURCE = `import { z } from 'zod/v4';
|
|
12
|
+
export const ProbeItem = z.object({
|
|
13
|
+
id: z.string(),
|
|
14
|
+
userId: z.string(),
|
|
15
|
+
title: z.string().min(1).max(40),
|
|
16
|
+
code: z.string().regex(/^[a-z]+-\\d+$/i).optional(),
|
|
17
|
+
tags: z.array(z.string().min(2)).max(3),
|
|
18
|
+
score: z.number().min(0).max(1),
|
|
19
|
+
count: z.number().int().nonnegative(),
|
|
20
|
+
kind: z.enum(['a', 'b']),
|
|
21
|
+
nested: z.object({ x: z.string(), depth: z.object({ level: z.number().int() }).optional() }).optional(),
|
|
22
|
+
publishedAt: z.iso.datetime().optional(),
|
|
23
|
+
note: z.string().nullable(),
|
|
24
|
+
rating: z.number().gt(0).min(0).lt(5).max(5).nullable(),
|
|
25
|
+
items: z.array(z.object({ x: z.string().min(1) })).optional(),
|
|
26
|
+
kinds: z.array(z.enum(['a', 'b'])).optional(),
|
|
27
|
+
metadata: z.object({}).optional(),
|
|
28
|
+
createdAt: z.string().optional(),
|
|
29
|
+
updatedAt: z.string().optional(),
|
|
30
|
+
});
|
|
31
|
+
export type ProbeItem = z.infer<typeof ProbeItem>;
|
|
32
|
+
export const partitionKey = '/userId';
|
|
33
|
+
export const displayName = 'Probe';
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
// Same schema as MODEL_SOURCE; used to validate C# output exactly as the BFF responseSchema would.
|
|
37
|
+
const ProbeItem = z.object({
|
|
38
|
+
id: z.string(),
|
|
39
|
+
userId: z.string(),
|
|
40
|
+
title: z.string().min(1).max(40),
|
|
41
|
+
code: z.string().regex(/^[a-z]+-\d+$/i).optional(),
|
|
42
|
+
tags: z.array(z.string().min(2)).max(3),
|
|
43
|
+
score: z.number().min(0).max(1),
|
|
44
|
+
count: z.number().int().nonnegative(),
|
|
45
|
+
kind: z.enum(["a", "b"]),
|
|
46
|
+
nested: z.object({ x: z.string(), depth: z.object({ level: z.number().int() }).optional() }).optional(),
|
|
47
|
+
publishedAt: z.iso.datetime().optional(),
|
|
48
|
+
note: z.string().nullable(),
|
|
49
|
+
rating: z.number().gt(0).min(0).lt(5).max(5).nullable(),
|
|
50
|
+
items: z.array(z.object({ x: z.string().min(1) })).optional(),
|
|
51
|
+
kinds: z.array(z.enum(["a", "b"])).optional(),
|
|
52
|
+
metadata: z.object({}).optional(),
|
|
53
|
+
createdAt: z.string().optional(),
|
|
54
|
+
updatedAt: z.string().optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const VALID_INPUTS = [
|
|
58
|
+
{
|
|
59
|
+
id: "1",
|
|
60
|
+
userId: "user-1",
|
|
61
|
+
title: "Probe",
|
|
62
|
+
code: "ABC-12",
|
|
63
|
+
tags: ["aa", "bb"],
|
|
64
|
+
score: 0.25,
|
|
65
|
+
count: 3,
|
|
66
|
+
kind: "b",
|
|
67
|
+
nested: { x: "value", depth: { level: 2 } },
|
|
68
|
+
publishedAt: "2024-05-06T07:08:09.123Z",
|
|
69
|
+
note: "note",
|
|
70
|
+
rating: 2.5,
|
|
71
|
+
items: [{ x: "a" }, { x: "b" }],
|
|
72
|
+
kinds: ["a", "b"],
|
|
73
|
+
metadata: {},
|
|
74
|
+
createdAt: "2024-05-06T07:08:09.123456+09:00",
|
|
75
|
+
updatedAt: "not a timestamp but still a string",
|
|
76
|
+
},
|
|
77
|
+
{ id: "2", userId: "user-2", title: "Minimal", tags: [], score: 1, count: 0, kind: "a", note: null, rating: null },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const INVALID_INPUTS: Array<{ input: Record<string, unknown>; members: string[] }> = [
|
|
81
|
+
{ input: { ...VALID_INPUTS[1], title: "" }, members: ["title"] },
|
|
82
|
+
{ input: { ...VALID_INPUTS[1], title: "x".repeat(41) }, members: ["title"] },
|
|
83
|
+
{ input: { ...VALID_INPUTS[1], code: "no-digits" }, members: ["code"] },
|
|
84
|
+
{ input: { ...VALID_INPUTS[1], tags: ["a"] }, members: ["tags[0]"] },
|
|
85
|
+
{ input: { ...VALID_INPUTS[1], tags: ["aa", "bb", "cc", "dd"] }, members: ["tags"] },
|
|
86
|
+
{ input: { ...VALID_INPUTS[1], score: 1.5 }, members: ["score"] },
|
|
87
|
+
{ input: { ...VALID_INPUTS[1], count: -1 }, members: ["count"] },
|
|
88
|
+
{ input: { ...VALID_INPUTS[1], nested: { depth: { level: 1 } } }, members: ["nested.x"] },
|
|
89
|
+
{ input: { ...VALID_INPUTS[1], rating: 0 }, members: ["rating"] },
|
|
90
|
+
{ input: { ...VALID_INPUTS[1], rating: 5 }, members: ["rating"] },
|
|
91
|
+
{ input: { ...VALID_INPUTS[1], items: [{ x: "ok" }, { x: "" }] }, members: ["items[1].x"] },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function createModel(): { root: string; modelPath: string } {
|
|
95
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-csharp-schema-"));
|
|
96
|
+
const modelPath = path.join(root, "shared", "models", "probe-item.ts");
|
|
97
|
+
fs.mkdirSync(path.dirname(modelPath), { recursive: true });
|
|
98
|
+
fs.writeFileSync(modelPath, MODEL_SOURCE);
|
|
99
|
+
return { root, modelPath };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("Zod -> OpenAPI -> C# conversion", () => {
|
|
103
|
+
let root: string;
|
|
104
|
+
let modelPath: string;
|
|
105
|
+
const originalCwd = process.cwd();
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
({ root, modelPath } = createModel());
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterEach(() => {
|
|
112
|
+
process.chdir(originalCwd);
|
|
113
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("records formats, integers, constraints, and inline object shapes from Zod", async () => {
|
|
117
|
+
const model = await parseModelFile(modelPath);
|
|
118
|
+
const field = (name: string) => model.fields.find((candidate) => candidate.name === name)!;
|
|
119
|
+
expect(model.parserMode).toBe("static-ast");
|
|
120
|
+
expect(field("createdAt")).toMatchObject({ type: "string", isOptional: true });
|
|
121
|
+
expect(field("createdAt").format).toBeUndefined();
|
|
122
|
+
expect(field("publishedAt").format).toBe("date-time");
|
|
123
|
+
expect(field("title").constraints).toEqual({ minLength: 1, maxLength: 40 });
|
|
124
|
+
expect(field("code").constraints).toEqual({ pattern: "^[a-z]+-\\d+$", patternFlags: "i" });
|
|
125
|
+
expect(field("tags").constraints).toEqual({ minLength: 2, maxItems: 3 });
|
|
126
|
+
expect(field("score")).toMatchObject({ constraints: { minimum: 0, maximum: 1 } });
|
|
127
|
+
expect(field("score").isInteger).toBeUndefined();
|
|
128
|
+
expect(field("count")).toMatchObject({ isInteger: true, constraints: { minimum: 0 } });
|
|
129
|
+
expect(field("nested").objectFields?.map((child) => child.name)).toEqual(["x", "depth"]);
|
|
130
|
+
expect(field("nested").objectFields?.[1].objectFields?.[0]).toMatchObject({ name: "level", isInteger: true });
|
|
131
|
+
expect(field("rating")).toMatchObject({
|
|
132
|
+
isNullable: true,
|
|
133
|
+
constraints: { minimum: 0, exclusiveMinimum: true, maximum: 5, exclusiveMaximum: true },
|
|
134
|
+
});
|
|
135
|
+
expect(field("metadata").objectFields).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("emits typed OpenAPI components without guessing formats from names", async () => {
|
|
139
|
+
const model = await parseModelFile(modelPath);
|
|
140
|
+
const schemas = JSON.parse(generateOpenApiDocument([model], model)).components.schemas;
|
|
141
|
+
const properties = schemas.ProbeItem.properties;
|
|
142
|
+
expect(properties.createdAt).toEqual({ type: "string" });
|
|
143
|
+
expect(properties.publishedAt).toEqual({ type: "string", format: "date-time" });
|
|
144
|
+
expect(properties.score).toEqual({ type: "number", format: "double", minimum: 0, maximum: 1 });
|
|
145
|
+
expect(properties.count).toEqual({ type: "integer", format: "int64", minimum: 0 });
|
|
146
|
+
expect(properties.title).toEqual({ type: "string", minLength: 1, maxLength: 40 });
|
|
147
|
+
expect(properties.code).toEqual({ type: "string", pattern: "^[a-z]+-\\d+$" });
|
|
148
|
+
expect(properties.tags).toEqual({ type: "array", items: { type: "string", minLength: 2 }, maxItems: 3 });
|
|
149
|
+
expect(properties.nested).toEqual({ $ref: "#/components/schemas/ProbeItemNested" });
|
|
150
|
+
expect(schemas.ProbeItemNested).toEqual({
|
|
151
|
+
type: "object",
|
|
152
|
+
properties: { x: { type: "string" }, depth: { $ref: "#/components/schemas/ProbeItemNestedDepth" } },
|
|
153
|
+
required: ["x"],
|
|
154
|
+
});
|
|
155
|
+
expect(schemas.ProbeItemNestedDepth.properties.level).toEqual({ type: "integer", format: "int64" });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("generates C# types and validation from the Zod contract", async () => {
|
|
159
|
+
const model = await parseModelFile(modelPath);
|
|
160
|
+
process.chdir(root);
|
|
161
|
+
await generateLanguageSchemaArtifacts([model], model, "functions", "csharp", new FileOperationSession("commit", root));
|
|
162
|
+
const modelDir = path.join(root, "functions", "generated", "csharp-models", "src", "SwallowKitBackendModels", "Model");
|
|
163
|
+
const source = fs.readFileSync(path.join(modelDir, "ProbeItem.cs"), "utf8");
|
|
164
|
+
|
|
165
|
+
expect(source).toContain("public string? CreatedAt");
|
|
166
|
+
expect(source).toContain("public DateTime? PublishedAt");
|
|
167
|
+
expect(source).toContain("public double Score");
|
|
168
|
+
expect(source).toContain("public long Count");
|
|
169
|
+
expect(source).toContain("public ProbeItemNested? Nested");
|
|
170
|
+
expect(source).not.toMatch(/Dictionary<string, object>|decimal|DateTime\? CreatedAt/);
|
|
171
|
+
expect(source).not.toContain("yield break;");
|
|
172
|
+
expect(source).toContain('Title.Length < 1');
|
|
173
|
+
expect(source).toContain('Regex.IsMatch(Code, "^[a-z]+-\\\\d+$", RegexOptions.IgnoreCase)');
|
|
174
|
+
expect(source).toContain("Score > 1");
|
|
175
|
+
expect(source).toContain("Count < 0");
|
|
176
|
+
expect(fs.readFileSync(path.join(modelDir, "ProbeItemNested.cs"), "utf8")).toContain("public ProbeItemNestedDepth? Depth");
|
|
177
|
+
expect(fs.readFileSync(path.join(modelDir, "ProbeItemNestedDepth.cs"), "utf8")).toContain("public long Level");
|
|
178
|
+
expect(source).toContain("public string? Note");
|
|
179
|
+
expect(source).not.toContain("if (Note is null)");
|
|
180
|
+
expect(source).toContain("public double? Rating");
|
|
181
|
+
expect(source).toContain("Rating.HasValue && Rating.Value <= 0");
|
|
182
|
+
expect(source).toContain("public List<ProbeItemItems>? Items");
|
|
183
|
+
expect(source).toContain("public List<KindsEnum>? Kinds");
|
|
184
|
+
const metadata = fs.readFileSync(path.join(modelDir, "ProbeItemMetadata.cs"), "utf8");
|
|
185
|
+
expect(metadata.match(/public ProbeItemMetadata\(\)/g)).toHaveLength(1);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const dotnetVersion = spawnSync("dotnet", ["--version"], { encoding: "utf8" });
|
|
190
|
+
const itWithDotnet = dotnetVersion.status === 0 ? it : it.skip;
|
|
191
|
+
|
|
192
|
+
describe("generated C# JSON round trip", () => {
|
|
193
|
+
itWithDotnet("serializes back to JSON that passes the same Zod schema, and rejects what Zod rejects", async () => {
|
|
194
|
+
const { root, modelPath } = createModel();
|
|
195
|
+
const originalCwd = process.cwd();
|
|
196
|
+
try {
|
|
197
|
+
const model = await parseModelFile(modelPath);
|
|
198
|
+
process.chdir(root);
|
|
199
|
+
await generateLanguageSchemaArtifacts([model], model, "functions", "csharp", new FileOperationSession("commit", root));
|
|
200
|
+
process.chdir(originalCwd);
|
|
201
|
+
|
|
202
|
+
const app = path.join(root, "roundtrip");
|
|
203
|
+
fs.mkdirSync(app);
|
|
204
|
+
const major = String(dotnetVersion.stdout).trim().split(".")[0];
|
|
205
|
+
fs.writeFileSync(path.join(app, "RoundTrip.csproj"), `<Project Sdk="Microsoft.NET.Sdk">
|
|
206
|
+
<PropertyGroup>
|
|
207
|
+
<OutputType>Exe</OutputType>
|
|
208
|
+
<TargetFramework>net${major}.0</TargetFramework>
|
|
209
|
+
<Nullable>enable</Nullable>
|
|
210
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
211
|
+
</PropertyGroup>
|
|
212
|
+
<ItemGroup>
|
|
213
|
+
<Compile Include="../functions/generated/csharp-models/**/*.cs" />
|
|
214
|
+
</ItemGroup>
|
|
215
|
+
</Project>
|
|
216
|
+
`);
|
|
217
|
+
fs.writeFileSync(path.join(app, "Program.cs"), `using System.ComponentModel.DataAnnotations;
|
|
218
|
+
using System.Text.Json;
|
|
219
|
+
using SwallowKitBackendModels.Model;
|
|
220
|
+
|
|
221
|
+
var results = new List<object>();
|
|
222
|
+
foreach (var file in args)
|
|
223
|
+
{
|
|
224
|
+
var item = JsonSerializer.Deserialize<ProbeItem>(File.ReadAllText(file))!;
|
|
225
|
+
var errors = new List<ValidationResult>();
|
|
226
|
+
Validator.TryValidateObject(item, new ValidationContext(item), errors, true);
|
|
227
|
+
results.Add(new { json = JsonSerializer.Serialize(item), members = errors.SelectMany(error => error.MemberNames).ToArray() });
|
|
228
|
+
}
|
|
229
|
+
Console.WriteLine(JsonSerializer.Serialize(results));
|
|
230
|
+
`);
|
|
231
|
+
const inputs = [...VALID_INPUTS, ...INVALID_INPUTS.map((entry) => entry.input)];
|
|
232
|
+
const files = inputs.map((input, index) => {
|
|
233
|
+
const file = path.join(root, `input-${index}.json`);
|
|
234
|
+
fs.writeFileSync(file, JSON.stringify(input));
|
|
235
|
+
return file;
|
|
236
|
+
});
|
|
237
|
+
const run = spawnSync("dotnet", ["run", "--project", app, "--", ...files], {
|
|
238
|
+
encoding: "utf8",
|
|
239
|
+
timeout: 240000,
|
|
240
|
+
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: "1", DOTNET_NOLOGO: "1" },
|
|
241
|
+
});
|
|
242
|
+
if (run.status !== 0) throw new Error(`dotnet run failed (${run.status}):\n${run.stdout}\n${run.stderr}`);
|
|
243
|
+
const output = JSON.parse(run.stdout.trim().split(/\r?\n/).pop()!) as Array<{ json: string; members: string[] }>;
|
|
244
|
+
|
|
245
|
+
VALID_INPUTS.forEach((input, index) => {
|
|
246
|
+
expect(ProbeItem.safeParse(input).success).toBe(true);
|
|
247
|
+
expect(output[index].members).toEqual([]);
|
|
248
|
+
const roundTripped = JSON.parse(output[index].json);
|
|
249
|
+
expect(ProbeItem.safeParse(roundTripped).success).toBe(true);
|
|
250
|
+
expect(roundTripped).toEqual(input);
|
|
251
|
+
});
|
|
252
|
+
INVALID_INPUTS.forEach(({ input, members }, offset) => {
|
|
253
|
+
expect(ProbeItem.safeParse(input).success).toBe(false);
|
|
254
|
+
expect(output[VALID_INPUTS.length + offset].members).toEqual(members);
|
|
255
|
+
});
|
|
256
|
+
} finally {
|
|
257
|
+
process.chdir(originalCwd);
|
|
258
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
}, 300000);
|
|
261
|
+
});
|