swallowkit 2.0.0-alpha.1 → 2.0.0-alpha.11
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 +14 -0
- package/README.md +14 -0
- package/dist/cli/commands/add-auth.js +37 -6
- package/dist/cli/commands/add-auth.js.map +1 -1
- 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/dev.d.ts +2 -2
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +39 -27
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/init.d.ts +8 -2
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +67 -132
- 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 +5 -0
- package/dist/cli/commands/provision.d.ts.map +1 -1
- package/dist/cli/commands/provision.js +278 -55
- 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 +85 -3
- 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 +43 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/core/operations/create-model.d.ts +2 -0
- package/dist/core/operations/create-model.d.ts.map +1 -1
- package/dist/core/operations/create-model.js +60 -8
- package/dist/core/operations/create-model.js.map +1 -1
- package/dist/core/operations/migration-generator.d.ts.map +1 -1
- package/dist/core/operations/migration-generator.js +12 -1
- package/dist/core/operations/migration-generator.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-deployment.d.ts +52 -0
- package/dist/core/operations/provision-deployment.d.ts.map +1 -0
- package/dist/core/operations/provision-deployment.js +164 -0
- package/dist/core/operations/provision-deployment.js.map +1 -0
- package/dist/core/operations/provision-operations.d.ts +62 -6
- package/dist/core/operations/provision-operations.d.ts.map +1 -1
- package/dist/core/operations/provision-operations.js +653 -115
- 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/agent-instructions.d.ts.map +1 -1
- package/dist/core/project/agent-instructions.js +4 -1
- package/dist/core/project/agent-instructions.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 +51 -20
- package/dist/core/project/boundaries.js.map +1 -1
- package/dist/core/project/capabilities.d.ts +10 -0
- package/dist/core/project/capabilities.d.ts.map +1 -1
- package/dist/core/project/capabilities.js +38 -6
- package/dist/core/project/capabilities.js.map +1 -1
- package/dist/core/project/home-page.d.ts +4 -0
- package/dist/core/project/home-page.d.ts.map +1 -0
- package/dist/core/project/home-page.js +113 -0
- package/dist/core/project/home-page.js.map +1 -0
- 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/react-project.d.ts +1 -1
- package/dist/core/project/react-project.d.ts.map +1 -1
- package/dist/core/project/react-project.js +46 -25
- package/dist/core/project/react-project.js.map +1 -1
- package/dist/core/project/state.d.ts +7 -0
- package/dist/core/project/state.d.ts.map +1 -1
- package/dist/core/project/state.js +86 -0
- package/dist/core/project/state.js.map +1 -1
- package/dist/core/project/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +72 -12
- package/dist/core/project/workflows.js.map +1 -1
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
- package/dist/core/scaffold/auth-generator.js +26 -27
- package/dist/core/scaffold/auth-generator.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 +13 -0
- package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
- package/dist/core/scaffold/native-schema-generator.js +294 -91
- 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 +4 -3
- package/dist/core/verify/index.d.ts.map +1 -1
- package/dist/core/verify/index.js +122 -5
- package/dist/core/verify/index.js.map +1 -1
- package/dist/machine/contracts.d.ts +1 -0
- package/dist/machine/contracts.d.ts.map +1 -1
- package/dist/machine/errors.d.ts +3 -2
- package/dist/machine/errors.d.ts.map +1 -1
- package/dist/machine/errors.js +2 -1
- package/dist/machine/errors.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +59 -28
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts +6 -4
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +210 -25
- 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 +56 -0
- package/dist/utils/azure-cli.d.ts.map +1 -0
- package/dist/utils/azure-cli.js +326 -0
- package/dist/utils/azure-cli.js.map +1 -0
- package/dist/utils/azure-diagnostics.d.ts +16 -0
- package/dist/utils/azure-diagnostics.d.ts.map +1 -0
- package/dist/utils/azure-diagnostics.js +68 -0
- package/dist/utils/azure-diagnostics.js.map +1 -0
- package/dist/utils/package-manager.js +2 -2
- package/dist/utils/package-manager.js.map +1 -1
- package/dist/utils/python-uv.d.ts +5 -7
- package/dist/utils/python-uv.d.ts.map +1 -1
- package/dist/utils/python-uv.js +50 -24
- package/dist/utils/python-uv.js.map +1 -1
- package/dist/utils/react-dev.d.ts +4 -0
- package/dist/utils/react-dev.d.ts.map +1 -0
- package/dist/utils/react-dev.js +35 -0
- package/dist/utils/react-dev.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 +6 -3
- package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
- package/src/__tests__/agent-instructions.test.ts +93 -0
- package/src/__tests__/agent-loop-phase34.test.ts +143 -8
- package/src/__tests__/auth.test.ts +9 -0
- package/src/__tests__/azure-cli.test.ts +202 -0
- package/src/__tests__/boundaries.test.ts +42 -0
- package/src/__tests__/csharp-schema.test.ts +261 -0
- package/src/__tests__/dev.test.ts +13 -6
- package/src/__tests__/fixtures/az wrapper.cmd +2 -0
- package/src/__tests__/fixtures/azure-cli.cjs +153 -0
- package/src/__tests__/fixtures/provision-runner.cjs +28 -0
- package/src/__tests__/generated-project-matrix.test.ts +789 -0
- package/src/__tests__/home-page.test.ts +50 -0
- package/src/__tests__/init.test.ts +116 -0
- package/src/__tests__/mcp-version.test.ts +262 -0
- package/src/__tests__/mcp.test.ts +120 -3
- package/src/__tests__/multi-auth.test.ts +115 -4
- package/src/__tests__/package-manager.test.ts +17 -2
- package/src/__tests__/plan-apply.test.ts +179 -0
- package/src/__tests__/provision-runtime.test.ts +722 -0
- package/src/__tests__/python-uv.test.ts +50 -14
- package/src/__tests__/python-venv.test.ts +48 -0
- package/src/__tests__/react-dev.test.ts +72 -0
- package/src/__tests__/react.test.ts +19 -0
- package/src/__tests__/schema-analysis.test.ts +329 -0
- package/src/cli/commands/add-auth.ts +36 -6
- package/src/cli/commands/configure-mcp.ts +54 -0
- package/src/cli/commands/dev.ts +40 -31
- package/src/cli/commands/init.ts +73 -138
- package/src/cli/commands/migrations.ts +22 -0
- package/src/cli/commands/provision.ts +294 -56
- package/src/cli/commands/scaffold.ts +105 -3
- package/src/cli/index.ts +40 -7
- package/src/core/operations/create-model.ts +51 -13
- package/src/core/operations/migration-generator.ts +14 -1
- package/src/core/operations/model-migration.ts +52 -0
- package/src/core/operations/provision-deployment.ts +183 -0
- package/src/core/operations/provision-operations.ts +662 -154
- 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-instructions.ts +4 -1
- package/src/core/project/agent-templates/AGENTS.md +126 -6
- package/src/core/project/agent-templates/CLAUDE.md +14 -1
- package/src/core/project/agent-templates/copilot-instructions.md +15 -1
- package/src/core/project/agent-templates/shared-models.instructions.md +23 -3
- package/src/core/project/boundaries.ts +62 -20
- package/src/core/project/capabilities.ts +49 -6
- package/src/core/project/home-page.ts +114 -0
- package/src/core/project/mcp-config.ts +84 -0
- package/src/core/project/react-project.ts +50 -28
- package/src/core/project/state.ts +70 -0
- package/src/core/project/workflows.ts +72 -12
- package/src/core/scaffold/auth-generator.ts +26 -27
- package/src/core/scaffold/model-parser.ts +413 -129
- package/src/core/scaffold/native-schema-generator.ts +319 -103
- package/src/core/scaffold/openapi-generator.ts +63 -5
- package/src/core/scaffold/ui-generator.ts +70 -8
- package/src/core/verify/index.ts +118 -6
- package/src/machine/contracts.ts +1 -0
- package/src/machine/errors.ts +4 -2
- package/src/machine/index.ts +65 -34
- package/src/mcp/index.ts +221 -31
- package/src/mcp/version-selection.ts +163 -0
- package/src/utils/azure-cli.ts +315 -0
- package/src/utils/azure-diagnostics.ts +75 -0
- package/src/utils/package-manager.ts +2 -2
- package/src/utils/python-uv.ts +53 -33
- package/src/utils/react-dev.ts +31 -0
- package/src/utils/semver.ts +59 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
|
+
import { BackendLanguage, FrontendFramework } from "../types";
|
|
5
|
+
import { PackageManager, getCommands } from "../utils/package-manager";
|
|
6
|
+
import { buildWorkflowDocs } from "../core/project/workflows";
|
|
4
7
|
import {
|
|
5
8
|
AGENT_TEMPLATE_FILES,
|
|
6
9
|
AgentInstructionContext,
|
|
@@ -19,6 +22,19 @@ const representativeContext: AgentInstructionContext = {
|
|
|
19
22
|
};
|
|
20
23
|
|
|
21
24
|
const repositoryRoot = path.resolve(__dirname, "../..");
|
|
25
|
+
const frontends: FrontendFramework[] = ["nextjs", "react"];
|
|
26
|
+
const backendLanguages: BackendLanguage[] = ["typescript", "csharp", "python"];
|
|
27
|
+
const packageManagers: PackageManager[] = ["npm", "pnpm"];
|
|
28
|
+
const persistentModelContexts: AgentInstructionContext[] = frontends.flatMap((frontend) =>
|
|
29
|
+
backendLanguages.flatMap((backendLanguage) =>
|
|
30
|
+
packageManagers.map((packageManager) => ({
|
|
31
|
+
projectName: "persistent-app",
|
|
32
|
+
frontend,
|
|
33
|
+
backendLanguage,
|
|
34
|
+
packageManager,
|
|
35
|
+
}))
|
|
36
|
+
)
|
|
37
|
+
);
|
|
22
38
|
|
|
23
39
|
describe("coding-agent instruction contract", () => {
|
|
24
40
|
it("applies SwallowKit before implementation and composes with external processes", () => {
|
|
@@ -57,6 +73,10 @@ describe("coding-agent instruction contract", () => {
|
|
|
57
73
|
expect(agentsMd).toContain("Verification Is a Completion Gate");
|
|
58
74
|
expect(agentsMd).toContain("semantic code review answers different questions");
|
|
59
75
|
expect(agentsMd).toContain("When the status is `requires-human`, stop");
|
|
76
|
+
expect(agentsMd).toContain("machine inspect provision --plan <runId> --wait-seconds 300");
|
|
77
|
+
expect(agentsMd).toContain("outcome: \"succeeded\"");
|
|
78
|
+
expect(agentsMd).toContain("Never re-submit Bicep");
|
|
79
|
+
expect(agentsMd).toContain("must not switch to normal CLI to bypass it");
|
|
60
80
|
expect(agentsMd).toContain("never bypass its\napproval gate");
|
|
61
81
|
});
|
|
62
82
|
|
|
@@ -88,6 +108,7 @@ describe("coding-agent instruction contract", () => {
|
|
|
88
108
|
expect(adapter).toContain("planning");
|
|
89
109
|
expect(adapter).toContain("Never hand-edit deterministic SwallowKit-managed artifacts");
|
|
90
110
|
expect(adapter).toContain("SwallowKit verification");
|
|
111
|
+
expect(adapter).toContain("approval");
|
|
91
112
|
}
|
|
92
113
|
});
|
|
93
114
|
|
|
@@ -128,6 +149,66 @@ describe("coding-agent instruction contract", () => {
|
|
|
128
149
|
}
|
|
129
150
|
});
|
|
130
151
|
|
|
152
|
+
it.each(persistentModelContexts)(
|
|
153
|
+
"writes the init persistent-model contract for $frontend/$backendLanguage/$packageManager",
|
|
154
|
+
(context) => {
|
|
155
|
+
const tempDir = fs.mkdtempSync(path.join(repositoryRoot, ".tmp-persistent-agent-docs-"));
|
|
156
|
+
try {
|
|
157
|
+
// init uses this writer for both frontend variants.
|
|
158
|
+
writeAgentInstructionFiles(tempDir, context);
|
|
159
|
+
const agents = fs.readFileSync(path.join(tempDir, "AGENTS.md"), "utf-8").replace(/\s+/g, " ");
|
|
160
|
+
const sharedModels = fs.readFileSync(
|
|
161
|
+
path.join(tempDir, ".github", "instructions", AGENT_TEMPLATE_FILES.sharedModelsInstructions),
|
|
162
|
+
"utf-8"
|
|
163
|
+
);
|
|
164
|
+
const runCmd = getCommands(context.packageManager).exec;
|
|
165
|
+
for (const document of [agents, sharedModels]) {
|
|
166
|
+
expect(document).toContain(`${runCmd} swallowkit machine generate model <model> --overwrite never`);
|
|
167
|
+
expect(document).toContain(`${runCmd} swallowkit migrations add-model <model> --dry-run`);
|
|
168
|
+
expect(document).toContain(`${runCmd} swallowkit migrations add-model <model>`);
|
|
169
|
+
expect(document).toContain("custom APIs");
|
|
170
|
+
expect(document).toContain("required infrastructure is missing");
|
|
171
|
+
expect(document).not.toMatch(/\{\{[A-Za-z]+\}\}/);
|
|
172
|
+
}
|
|
173
|
+
expect(agents).toContain(`${runCmd} swallowkit create-model <model>`);
|
|
174
|
+
expect(agents).toContain("A hand-written model alone is not complete");
|
|
175
|
+
expect(agents).toContain("does **not** generate a Cosmos DB container");
|
|
176
|
+
expect(agents).toContain("do **not** scaffold unnecessary CRUD");
|
|
177
|
+
expect(agents).toContain("`--api-only` skips UI only");
|
|
178
|
+
expect(agents).toContain("`machine apply scaffold --plan <planId>`");
|
|
179
|
+
expect(agents).toContain(`${runCmd} swallowkit machine verify project`);
|
|
180
|
+
for (const inspect of ["entities", "infra", "artifacts", "drift"]) {
|
|
181
|
+
expect(agents).toContain(`machine inspect ${inspect}`);
|
|
182
|
+
}
|
|
183
|
+
for (const record of ["infra/migrations/manifest.json", ".swallowkit/artifacts.json", "sourceModel"]) {
|
|
184
|
+
expect(agents).toContain(record);
|
|
185
|
+
}
|
|
186
|
+
expect(agents).toContain("container name and partition key");
|
|
187
|
+
expect(agents).toContain("baseline-owned container");
|
|
188
|
+
expect(agents).toContain("External `connectorConfig` models");
|
|
189
|
+
expect(agents).toContain("state the reason and the pending");
|
|
190
|
+
expect(agents).toContain("Even if `verify project` passes");
|
|
191
|
+
expect(agents).toContain("Generation is local, not Azure provisioning");
|
|
192
|
+
} finally {
|
|
193
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
it.each(["npx", "pnpm exec"])("aligns the add-model runbook and skill content for %s", (runCmd) => {
|
|
199
|
+
const workflow = buildWorkflowDocs(runCmd).find((doc) => doc.skillName === "swallowkit-add-model");
|
|
200
|
+
expect(workflow).toBeDefined();
|
|
201
|
+
expect(workflow?.skillDescription).toContain("including models with custom APIs");
|
|
202
|
+
expect(workflow?.content).toContain(`${runCmd} swallowkit machine generate model <name> --overwrite never`);
|
|
203
|
+
expect(workflow?.content).toContain(`${runCmd} swallowkit create-model <name>`);
|
|
204
|
+
expect(workflow?.content).toContain("If custom APIs replace CRUD, skip scaffold");
|
|
205
|
+
expect(workflow?.content).toContain(`${runCmd} swallowkit migrations add-model <name> --dry-run`);
|
|
206
|
+
expect(workflow?.content).toContain(`${runCmd} swallowkit migrations add-model <name>`);
|
|
207
|
+
expect(workflow?.content).toContain("infra/migrations/manifest.json");
|
|
208
|
+
expect(workflow?.content).toContain(".swallowkit/artifacts.json");
|
|
209
|
+
expect(workflow?.content).toContain("a passing verify does not mean the model is complete or deployment-ready");
|
|
210
|
+
});
|
|
211
|
+
|
|
131
212
|
it("covers the requested model-change, new-feature, BFF, and deployment scenarios", () => {
|
|
132
213
|
const agentsMd = buildAgentsMd(representativeContext);
|
|
133
214
|
const pathInstructions = buildPathSpecificInstructions(representativeContext);
|
|
@@ -195,6 +276,18 @@ describe("coding-agent documentation synchronization", () => {
|
|
|
195
276
|
].map((match) => match[1]);
|
|
196
277
|
|
|
197
278
|
expect(translatedSourceSections).toEqual(sourceSections);
|
|
279
|
+
expect(translation).toContain("カスタム API の有無にかかわらず");
|
|
280
|
+
expect(translation).toContain("手書きモデルを追加しただけでは完了ではありません");
|
|
281
|
+
expect(translation).toContain("{{runCmd}} swallowkit machine generate model <model> --overwrite never");
|
|
282
|
+
expect(translation).toContain("{{runCmd}} swallowkit create-model <model>");
|
|
283
|
+
expect(translation).toContain("{{runCmd}} swallowkit migrations add-model <model> --dry-run");
|
|
284
|
+
expect(translation).toContain("{{runCmd}} swallowkit migrations add-model <model>");
|
|
285
|
+
expect(translation).toContain("不要な CRUD を scaffold してはなりません");
|
|
286
|
+
expect(translation).toContain("理由と未完了");
|
|
287
|
+
expect(translation).toContain("デプロイ可能と案内したりしてはなりません");
|
|
288
|
+
for (const record of ["infra/migrations/manifest.json", ".swallowkit/artifacts.json", "sourceModel"]) {
|
|
289
|
+
expect(translation).toContain(record);
|
|
290
|
+
}
|
|
198
291
|
});
|
|
199
292
|
|
|
200
293
|
it("exposes matching major EN/JA guide topics and both sidebar entries", () => {
|
|
@@ -17,6 +17,8 @@ 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";
|
|
21
|
+
import * as azureCli from "../utils/azure-cli";
|
|
20
22
|
import { createCustomMigration, initializeLegacyMigrations } from "../core/operations/migration-generator";
|
|
21
23
|
import { createMigrationManifest, encodeMigrationState, getMigrationTagKey, saveMigrationManifest } from "../core/project/migrations";
|
|
22
24
|
|
|
@@ -126,12 +128,17 @@ describe("agent loop phase 3/4", () => {
|
|
|
126
128
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-p34-"));
|
|
127
129
|
process.chdir(tempDir);
|
|
128
130
|
(childProcess.spawnSync as unknown as any).mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
131
|
+
// Platform-independent az invocation; the Windows wrapper resolution is covered in azure-cli.test.ts.
|
|
132
|
+
setAzCliInvocationForTesting({ command: "az", prefixArgs: [], source: "az" });
|
|
133
|
+
jest.spyOn(azureCli, "runAzCliAsync").mockImplementation(async (args) => azureCli.runAzCli(args));
|
|
129
134
|
});
|
|
130
135
|
|
|
131
136
|
afterEach(() => {
|
|
137
|
+
resetAzCliInvocationCache();
|
|
132
138
|
process.chdir(originalCwd);
|
|
133
139
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
134
140
|
jest.clearAllMocks();
|
|
141
|
+
jest.restoreAllMocks();
|
|
135
142
|
});
|
|
136
143
|
|
|
137
144
|
it("returns the responsibility boundary contract via inspect boundaries", async () => {
|
|
@@ -269,6 +276,7 @@ describe("agent loop phase 3/4", () => {
|
|
|
269
276
|
createProjectFixture(tempDir);
|
|
270
277
|
writeFile(path.join(tempDir, "infra", "main.bicep"), SAMPLE_MAIN_BICEP);
|
|
271
278
|
writeFile(path.join(tempDir, "infra", "main.parameters.json"), "{}\n");
|
|
279
|
+
setAzCliInvocationForTesting(null);
|
|
272
280
|
|
|
273
281
|
const plan = await runMachine([
|
|
274
282
|
"node", "swallowkit", "machine", "plan", "provision",
|
|
@@ -312,11 +320,11 @@ describe("agent loop phase 3/4", () => {
|
|
|
312
320
|
(childProcess.spawnSync as unknown as any).mockImplementation((_file: string, args: string[]) => {
|
|
313
321
|
if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
|
|
314
322
|
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: "true\n", stderr: "" };
|
|
315
|
-
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/
|
|
323
|
+
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-sample", tags: {} }), stderr: "" };
|
|
316
324
|
return { status: 1, stdout: "", stderr: "unexpected" };
|
|
317
325
|
});
|
|
318
326
|
|
|
319
|
-
const plan = await planProvisionOperation({ resourceGroup: "rg-sample", location: "japaneast", swaLocation: "eastasia" });
|
|
327
|
+
const plan = await planProvisionOperation({ resourceGroup: "rg-sample", location: "japaneast", swaLocation: "eastasia", subscription: "11111111-1111-1111-1111-111111111111" });
|
|
320
328
|
|
|
321
329
|
expect(plan.mode).toBe("adoption-required");
|
|
322
330
|
expect(plan.commands).toEqual([]);
|
|
@@ -331,7 +339,7 @@ describe("agent loop phase 3/4", () => {
|
|
|
331
339
|
(childProcess.spawnSync as unknown as any).mockImplementation((_file: string, args: string[]) => {
|
|
332
340
|
if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
|
|
333
341
|
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: "true\n", stderr: "" };
|
|
334
|
-
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/
|
|
342
|
+
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-sample", tags: {} }), stderr: "" };
|
|
335
343
|
if (args[0] === "deployment" && args[2] === "what-if") {
|
|
336
344
|
return { status: 0, stdout: JSON.stringify({ changes: [{ changeType: "Modify", resourceId: "/functions/sample" }] }), stderr: "" };
|
|
337
345
|
}
|
|
@@ -345,6 +353,7 @@ describe("agent loop phase 3/4", () => {
|
|
|
345
353
|
adoptExisting: true,
|
|
346
354
|
baseline: 0,
|
|
347
355
|
whatIf: true,
|
|
356
|
+
subscription: "11111111-1111-1111-1111-111111111111",
|
|
348
357
|
});
|
|
349
358
|
|
|
350
359
|
expect(plan.mode).toBe("adopt");
|
|
@@ -362,7 +371,7 @@ describe("agent loop phase 3/4", () => {
|
|
|
362
371
|
(childProcess.spawnSync as unknown as any).mockImplementation((_file: string, args: string[]) => {
|
|
363
372
|
if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
|
|
364
373
|
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: "true\n", stderr: "" };
|
|
365
|
-
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/
|
|
374
|
+
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-sample", tags: {} }), stderr: "" };
|
|
366
375
|
if (args[0] === "deployment" && args[2] === "what-if") return { status: 0, stdout: JSON.stringify({ changes: [] }), stderr: "" };
|
|
367
376
|
return { status: 1, stdout: "", stderr: "unexpected" };
|
|
368
377
|
});
|
|
@@ -372,6 +381,7 @@ describe("agent loop phase 3/4", () => {
|
|
|
372
381
|
location: "japaneast",
|
|
373
382
|
swaLocation: "eastasia",
|
|
374
383
|
reconcile: true,
|
|
384
|
+
subscription: "11111111-1111-1111-1111-111111111111",
|
|
375
385
|
});
|
|
376
386
|
|
|
377
387
|
expect(plan.mode).toBe("reconcile");
|
|
@@ -393,12 +403,12 @@ describe("agent loop phase 3/4", () => {
|
|
|
393
403
|
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: "true\n", stderr: "" };
|
|
394
404
|
if (args[0] === "group" && args[1] === "show") {
|
|
395
405
|
const tagKey = getMigrationTagKey(manifest.projectId);
|
|
396
|
-
return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/
|
|
406
|
+
return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-sample", tags: { [tagKey]: tagValue } }), stderr: "" };
|
|
397
407
|
}
|
|
398
408
|
return { status: 1, stdout: "", stderr: "unexpected" };
|
|
399
409
|
});
|
|
400
410
|
|
|
401
|
-
const plan = await planProvisionOperation({ resourceGroup: "rg-sample", location: "japaneast", swaLocation: "eastasia" });
|
|
411
|
+
const plan = await planProvisionOperation({ resourceGroup: "rg-sample", location: "japaneast", swaLocation: "eastasia", subscription: "11111111-1111-1111-1111-111111111111" });
|
|
402
412
|
|
|
403
413
|
expect(plan.mode).toBe("migrate");
|
|
404
414
|
expect(plan.pendingMigrations.map((migration) => migration.version)).toEqual([1]);
|
|
@@ -419,7 +429,13 @@ describe("agent loop phase 3/4", () => {
|
|
|
419
429
|
(childProcess.spawnSync as unknown as any).mockImplementation((_file: string, args: string[]) => {
|
|
420
430
|
if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
|
|
421
431
|
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: "true\n", stderr: "" };
|
|
422
|
-
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/
|
|
432
|
+
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-sample", tags: { [tagKey]: tagValue } }), stderr: "" };
|
|
433
|
+
if (args[0] === "deployment" && args[2] === "show") {
|
|
434
|
+
const name = args[args.indexOf("--name") + 1];
|
|
435
|
+
return deployedTemplates.length === 0
|
|
436
|
+
? { status: 1, stdout: "", stderr: "DeploymentNotFound" }
|
|
437
|
+
: { status: 0, stdout: JSON.stringify({ id: `/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-sample/providers/Microsoft.Resources/deployments/${name}`, state: "Succeeded", correlationId: "fixture-correlation" }), stderr: "" };
|
|
438
|
+
}
|
|
423
439
|
if (args[0] === "deployment" && args[2] === "create") {
|
|
424
440
|
deployedTemplates.push(args[args.indexOf("--template-file") + 1]);
|
|
425
441
|
return { status: 0, stdout: JSON.stringify({ properties: { outputs: {} } }), stderr: "" };
|
|
@@ -431,7 +447,7 @@ describe("agent loop phase 3/4", () => {
|
|
|
431
447
|
return { status: 1, stdout: "", stderr: `unexpected: ${args.join(" ")}` };
|
|
432
448
|
});
|
|
433
449
|
|
|
434
|
-
const plan = await planProvisionOperation({ resourceGroup: "rg-sample", location: "japaneast", swaLocation: "eastasia" });
|
|
450
|
+
const plan = await planProvisionOperation({ resourceGroup: "rg-sample", location: "japaneast", swaLocation: "eastasia", subscription: "11111111-1111-1111-1111-111111111111" });
|
|
435
451
|
const result = await applyProvisionOperation({ planId: plan.planId, approve: true });
|
|
436
452
|
|
|
437
453
|
expect(result.executedCommands).toHaveLength(2);
|
|
@@ -508,6 +524,30 @@ describe("agent loop phase 3/4", () => {
|
|
|
508
524
|
);
|
|
509
525
|
});
|
|
510
526
|
|
|
527
|
+
it.each<[string, string[]]>([
|
|
528
|
+
["pnpm", ["exec", "next", "typegen"]],
|
|
529
|
+
["npm", ["exec", "--", "next", "typegen"]],
|
|
530
|
+
])("generates Next.js route types with %s before typechecking", async (packageManager, typegenArgs) => {
|
|
531
|
+
writeFile(
|
|
532
|
+
path.join(tempDir, "package.json"),
|
|
533
|
+
JSON.stringify({ name: "sample-app", devEngines: { packageManager: { name: packageManager, version: ">=10", onFail: "error" } } })
|
|
534
|
+
);
|
|
535
|
+
writeFile(path.join(tempDir, "tsconfig.json"), JSON.stringify({ compilerOptions: { noEmit: true } }));
|
|
536
|
+
writeFile(path.join(tempDir, "next.config.ts"), "export default {};\n");
|
|
537
|
+
writeFile(path.join(tempDir, "node_modules", "next", "package.json"), JSON.stringify({ name: "next", version: "16.0.0" }));
|
|
538
|
+
const spawnSyncMock = childProcess.spawnSync as unknown as ReturnType<typeof jest.fn>;
|
|
539
|
+
spawnSyncMock.mockReset();
|
|
540
|
+
spawnSyncMock
|
|
541
|
+
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "typegen failed" })
|
|
542
|
+
.mockReturnValueOnce({ status: 2, stdout: "app/layout.tsx: Cannot find name 'LayoutProps'.", stderr: "" });
|
|
543
|
+
|
|
544
|
+
const result = await runVerify(["typecheck"], tempDir);
|
|
545
|
+
|
|
546
|
+
expect(spawnSyncMock.mock.calls[0].slice(0, 2)).toEqual([packageManager, typegenArgs]);
|
|
547
|
+
expect(result.checks[0].status).toBe("fail");
|
|
548
|
+
expect(result.checks[0].evidence.logTail?.[0]).toMatch(/^next typegen did not complete \(exit 1\)/);
|
|
549
|
+
});
|
|
550
|
+
|
|
511
551
|
it("uses the existing typecheck script when present", async () => {
|
|
512
552
|
writeFile(
|
|
513
553
|
path.join(tempDir, "package.json"),
|
|
@@ -530,6 +570,101 @@ describe("agent loop phase 3/4", () => {
|
|
|
530
570
|
);
|
|
531
571
|
});
|
|
532
572
|
|
|
573
|
+
it("includes standalone C# compilation in default verification and explains source errors", async () => {
|
|
574
|
+
createProjectFixture(tempDir);
|
|
575
|
+
writeFile(path.join(tempDir, "swallowkit.config.json"), JSON.stringify({ backend: { language: "csharp" } }));
|
|
576
|
+
// The fixture's .js config otherwise takes precedence.
|
|
577
|
+
fs.unlinkSync(path.join(tempDir, "swallowkit.config.js"));
|
|
578
|
+
fs.mkdirSync(path.join(tempDir, "shared", "models"), { recursive: true });
|
|
579
|
+
fs.mkdirSync(path.join(tempDir, "app", "api"), { recursive: true });
|
|
580
|
+
writeFile(path.join(tempDir, "functions", "SwallowKit.Functions.csproj"), "<Project />\n");
|
|
581
|
+
const spawnMock = childProcess.spawnSync as unknown as ReturnType<typeof jest.fn>;
|
|
582
|
+
spawnMock.mockImplementation((command: string, args: string[]) => ({
|
|
583
|
+
status: command === "dotnet" && args[0] === "build" ? 1 : 0,
|
|
584
|
+
stdout: command === "dotnet" && args[0] === "build"
|
|
585
|
+
? `${path.join(tempDir, "functions", "Crud", "ProbeItemCrudFunctions.cs")}(8,7): error CS0246: Functions namespace not found`
|
|
586
|
+
: "",
|
|
587
|
+
stderr: "",
|
|
588
|
+
}));
|
|
589
|
+
const result = await runVerify(undefined, tempDir);
|
|
590
|
+
expect(result.summary.done).toBe(false);
|
|
591
|
+
expect(result.checks).toContainEqual(expect.objectContaining({
|
|
592
|
+
id: "backend-build", status: "fail",
|
|
593
|
+
evidence: expect.objectContaining({ logTail: expect.arrayContaining([expect.stringContaining("(8,7): error CS0246")]) }),
|
|
594
|
+
}));
|
|
595
|
+
const explanation = await runMachine(["node", "swallowkit", "machine", "explain", "failure", "--check", "backend-build"]);
|
|
596
|
+
expect(JSON.stringify(explanation.response)).toContain("(8,7): error CS0246");
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it.each(["csharp", "python"] as const)("skips %s backend compilation when its toolchain is missing", async (language) => {
|
|
600
|
+
createProjectFixture(tempDir);
|
|
601
|
+
writeFile(path.join(tempDir, "swallowkit.config.js"), `module.exports = { backend: { language: '${language}' } };`);
|
|
602
|
+
const missing = Object.assign(new Error("not found"), { code: "ENOENT" });
|
|
603
|
+
(childProcess.spawnSync as unknown as ReturnType<typeof jest.fn>).mockReturnValue({ status: null, error: missing });
|
|
604
|
+
const result = await runVerify(["backend-build"], tempDir);
|
|
605
|
+
expect(result.checks[0]).toMatchObject({ id: "backend-build", status: "skip" });
|
|
606
|
+
expect(result.checks[0].evidence.diagnostics?.[0]).toMatch(/not installed|not available/);
|
|
607
|
+
expect(result.summary).toMatchObject({ skipped: 1, failed: 0 });
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("does not hide an installed backend toolchain execution failure as a skip", async () => {
|
|
611
|
+
createProjectFixture(tempDir);
|
|
612
|
+
writeFile(path.join(tempDir, "swallowkit.config.js"), "module.exports = { backend: { language: 'csharp' } };");
|
|
613
|
+
const denied = Object.assign(new Error("spawn EPERM"), { code: "EPERM" });
|
|
614
|
+
(childProcess.spawnSync as unknown as ReturnType<typeof jest.fn>).mockReturnValue({ status: null, error: denied });
|
|
615
|
+
const result = await runVerify(["backend-build"], tempDir);
|
|
616
|
+
expect(result.checks[0]).toMatchObject({ status: "error", evidence: { errorCode: "verification-command-unavailable" } });
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("uses the project's compiler and functions tsconfig for TypeScript backend verification", async () => {
|
|
620
|
+
createProjectFixture(tempDir);
|
|
621
|
+
fs.symlinkSync(path.join(repoRoot, "node_modules", "typescript"), path.join(tempDir, "node_modules", "typescript"), "junction");
|
|
622
|
+
const spawnMock = childProcess.spawnSync as unknown as ReturnType<typeof jest.fn>;
|
|
623
|
+
spawnMock.mockReturnValue({ status: 2, stdout: "src/probe-item.ts(9,3): error TS2322: Invalid type", stderr: "" });
|
|
624
|
+
const result = await runVerify(["backend-build"], tempDir);
|
|
625
|
+
expect(result.summary.done).toBe(false);
|
|
626
|
+
expect(result.checks[0].evidence.diagnostics).toContain("src/probe-item.ts(9,3): error TS2322: Invalid type");
|
|
627
|
+
expect(spawnMock).toHaveBeenCalledWith(process.execPath, [
|
|
628
|
+
expect.stringContaining(path.join("typescript", "bin", "tsc")), "--project", "tsconfig.json", "--noEmit",
|
|
629
|
+
], expect.objectContaining({ cwd: path.join(tempDir, "functions"), shell: false }));
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("prefers the existing Python dev virtualenv and includes source syntax errors", async () => {
|
|
633
|
+
createProjectFixture(tempDir);
|
|
634
|
+
writeFile(path.join(tempDir, "swallowkit.config.js"), "module.exports = { backend: { language: 'python' } };");
|
|
635
|
+
const python = path.join(tempDir, "functions", ".venv", ...(process.platform === "win32" ? ["Scripts", "python.exe"] : ["bin", "python"]));
|
|
636
|
+
writeFile(python, "mock interpreter");
|
|
637
|
+
const spawnMock = childProcess.spawnSync as unknown as ReturnType<typeof jest.fn>;
|
|
638
|
+
spawnMock.mockImplementation((_command: string, args: string[]) => args.includes("compileall")
|
|
639
|
+
? { status: 1, stdout: '*** Error compiling "./blueprints/probe_item.py"...\n File "./blueprints/probe_item.py", line 12\nSyntaxError: invalid syntax', stderr: "" }
|
|
640
|
+
: { status: 0, stdout: "", stderr: "" });
|
|
641
|
+
const result = await runVerify(["backend-build"], tempDir);
|
|
642
|
+
expect(result.checks[0].status).toBe("fail");
|
|
643
|
+
expect(result.checks[0].evidence.diagnostics).toContain(' File "./blueprints/probe_item.py", line 12');
|
|
644
|
+
expect(spawnMock).toHaveBeenCalledWith(python, [
|
|
645
|
+
"-m", "compileall", "-q", "-f", "-x", expect.stringContaining(".codegen-venv"), ".",
|
|
646
|
+
], expect.objectContaining({ cwd: path.join(tempDir, "functions"), shell: false }));
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it.each([
|
|
650
|
+
["python", "python"],
|
|
651
|
+
["unsupported", "typescript"],
|
|
652
|
+
])("resolves the backend like generation when SWALLOWKIT_BACKEND_LANGUAGE=%s", async (override, expected) => {
|
|
653
|
+
createProjectFixture(tempDir); // config file says typescript
|
|
654
|
+
fs.symlinkSync(path.join(repoRoot, "node_modules", "typescript"), path.join(tempDir, "node_modules", "typescript"), "junction");
|
|
655
|
+
const spawnMock = childProcess.spawnSync as unknown as ReturnType<typeof jest.fn>;
|
|
656
|
+
spawnMock.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
|
657
|
+
const previous = process.env.SWALLOWKIT_BACKEND_LANGUAGE;
|
|
658
|
+
process.env.SWALLOWKIT_BACKEND_LANGUAGE = override;
|
|
659
|
+
try {
|
|
660
|
+
const result = await runVerify(["backend-build"], tempDir);
|
|
661
|
+
expect(result.checks[0].evidence.command).toContain(expected === "python" ? "compileall" : "tsc");
|
|
662
|
+
} finally {
|
|
663
|
+
if (previous === undefined) delete process.env.SWALLOWKIT_BACKEND_LANGUAGE;
|
|
664
|
+
else process.env.SWALLOWKIT_BACKEND_LANGUAGE = previous;
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
533
668
|
it("reports verification command startup failures as blocked", async () => {
|
|
534
669
|
createProjectFixture(tempDir);
|
|
535
670
|
writeFile(path.join(tempDir, "tsconfig.json"), JSON.stringify({ compilerOptions: { noEmit: true } }));
|
|
@@ -300,6 +300,15 @@ describe("generateLoginPage", () => {
|
|
|
300
300
|
expect(code).toContain("handleSubmit");
|
|
301
301
|
expect(code).toContain("/api/auth/login");
|
|
302
302
|
});
|
|
303
|
+
|
|
304
|
+
it("keeps the shared form legible with either global color scheme", () => {
|
|
305
|
+
const code = generateLoginPage();
|
|
306
|
+
expect(code).toContain("bg-gray-50 dark:bg-gray-900");
|
|
307
|
+
expect(code).toContain("bg-white dark:bg-gray-800");
|
|
308
|
+
expect(code).toContain("text-gray-900 dark:text-gray-100");
|
|
309
|
+
expect(code).toContain("dark:border-gray-600");
|
|
310
|
+
expect(code).toContain("dark:text-red-300");
|
|
311
|
+
});
|
|
303
312
|
});
|
|
304
313
|
|
|
305
314
|
describe("generateAuthContext", () => {
|
|
@@ -0,0 +1,202 @@
|
|
|
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 * as azureCli from "../utils/azure-cli";
|
|
6
|
+
import {
|
|
7
|
+
buildBatchCommandLine,
|
|
8
|
+
checkAzCliAvailability,
|
|
9
|
+
resetAzCliInvocationCache,
|
|
10
|
+
resolveAzCliInvocation,
|
|
11
|
+
runAzCli,
|
|
12
|
+
setAzCliInvocationForTesting,
|
|
13
|
+
} from "../utils/azure-cli";
|
|
14
|
+
import { INTERACTIVE_PROVISION_RETRY_HINT, executeProvisionPlan, planProvisionOperation } from "../core/operations/provision-operations";
|
|
15
|
+
import { describeProvisionAction } from "../cli/commands/provision";
|
|
16
|
+
import { createMigrationManifest, saveMigrationManifest } from "../core/project/migrations";
|
|
17
|
+
import { formatCliError } from "../cli";
|
|
18
|
+
import { MachineCommandError } from "../machine/errors";
|
|
19
|
+
|
|
20
|
+
jest.mock("child_process", () => {
|
|
21
|
+
const actual = jest.requireActual<typeof import("child_process")>("child_process");
|
|
22
|
+
return { ...actual, spawnSync: jest.fn(actual.spawnSync) };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const spawnSyncMock = jest.mocked(childProcess.spawnSync) as unknown as ReturnType<typeof jest.fn>;
|
|
26
|
+
|
|
27
|
+
describe("Azure CLI resolution on Windows", () => {
|
|
28
|
+
let root: string;
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-az-"));
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
33
|
+
|
|
34
|
+
const touch = (file: string) => {
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
fs.writeFileSync(file, "");
|
|
37
|
+
return file;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
it("runs the MSI install's bundled Python the same way az.cmd does", () => {
|
|
41
|
+
const wrapper = touch(path.join(root, "CLI2", "wbin", "az.cmd"));
|
|
42
|
+
touch(path.join(root, "CLI2", "wbin", "az"));
|
|
43
|
+
const python = touch(path.join(root, "CLI2", "python.exe"));
|
|
44
|
+
const invocation = resolveAzCliInvocation({ PATH: `${path.join(root, "missing")};${path.dirname(wrapper)}`, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, "win32");
|
|
45
|
+
expect(invocation).toEqual({ command: python, prefixArgs: ["-IBm", "azure.cli"], env: { AZ_INSTALLER: "MSI" }, source: wrapper });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("uses an executable directly and other batch wrappers through cmd.exe", () => {
|
|
49
|
+
const exe = touch(path.join(root, "exe", "az.exe"));
|
|
50
|
+
expect(resolveAzCliInvocation({ PATH: path.dirname(exe) }, "win32")).toEqual({ command: exe, prefixArgs: [], source: exe });
|
|
51
|
+
const bat = touch(path.join(root, "venv", "Scripts", "az.bat"));
|
|
52
|
+
expect(resolveAzCliInvocation({ PATH: path.dirname(bat), ComSpec: "C:\\Windows\\system32\\cmd.exe" }, "win32"))
|
|
53
|
+
.toEqual({ command: "C:\\Windows\\system32\\cmd.exe", prefixArgs: [], batchScript: bat, source: bat });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reports not found when PATH has no az, and uses az directly elsewhere", () => {
|
|
57
|
+
expect(resolveAzCliInvocation({ PATH: root }, "win32")).toBeNull();
|
|
58
|
+
expect(resolveAzCliInvocation({ PATH: "/usr/bin" }, "linux")).toEqual({ command: "az", prefixArgs: [], source: "az" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("quotes batch arguments and refuses values cmd.exe would reinterpret", () => {
|
|
62
|
+
expect(buildBatchCommandLine("C:\\Program Files\\az.cmd", ["deployment", "--template-file", "C:\\my app\\main.bicep", "location=japaneast"]))
|
|
63
|
+
.toBe('""C:\\Program Files\\az.cmd" "deployment" "--template-file" "C:\\my app\\main.bicep" "location=japaneast""');
|
|
64
|
+
expect(() => buildBatchCommandLine("az.cmd", ["100%"])).toThrow(/cannot be passed safely/);
|
|
65
|
+
expect(() => buildBatchCommandLine("az.cmd", ['a"b'])).toThrow(/cannot be passed safely/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("Azure CLI invocation and availability", () => {
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
resetAzCliInvocationCache();
|
|
72
|
+
spawnSyncMock.mockReset();
|
|
73
|
+
spawnSyncMock.mockImplementation(jest.requireActual<typeof import("child_process")>("child_process").spawnSync);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("never uses a shell and prepends wrapper arguments", () => {
|
|
77
|
+
spawnSyncMock.mockReturnValue({ status: 0, stdout: "ok", stderr: "" });
|
|
78
|
+
const invocation = { command: "C:\\CLI2\\python.exe", prefixArgs: ["-IBm", "azure.cli"], env: { AZ_INSTALLER: "MSI" }, source: "az.cmd" };
|
|
79
|
+
expect(runAzCli(["group", "exists", "--name", "rg"], { invocation })).toMatchObject({ status: 0, stdout: "ok" });
|
|
80
|
+
const [command, args, options] = spawnSyncMock.mock.calls[0];
|
|
81
|
+
expect(command).toBe("C:\\CLI2\\python.exe");
|
|
82
|
+
expect(args).toEqual(["-IBm", "azure.cli", "group", "exists", "--name", "rg"]);
|
|
83
|
+
expect(options).toMatchObject({ shell: false, env: expect.objectContaining({ AZ_INSTALLER: "MSI" }) });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("runs batch wrappers through cmd.exe with verbatim quoted arguments", () => {
|
|
87
|
+
spawnSyncMock.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
|
88
|
+
runAzCli(["--version"], { invocation: { command: "cmd.exe", prefixArgs: [], batchScript: "C:\\az.bat", source: "C:\\az.bat" } });
|
|
89
|
+
const [command, args, options] = spawnSyncMock.mock.calls[0];
|
|
90
|
+
expect(command).toBe("cmd.exe");
|
|
91
|
+
expect(args).toEqual(["/d", "/s", "/v:off", "/c", '""C:\\az.bat" "--version""']);
|
|
92
|
+
expect(options).toMatchObject({ shell: false, windowsVerbatimArguments: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("distinguishes a missing CLI from one that cannot start", () => {
|
|
96
|
+
setAzCliInvocationForTesting(null);
|
|
97
|
+
expect(checkAzCliAvailability()).toEqual({ available: false, reason: "not-found", detail: "az was not found on PATH." });
|
|
98
|
+
|
|
99
|
+
setAzCliInvocationForTesting({ command: "az", prefixArgs: [], source: "az" });
|
|
100
|
+
spawnSyncMock.mockReturnValue({ status: null, stdout: "", stderr: "", error: Object.assign(new Error("spawn az ENOENT"), { code: "ENOENT" }) });
|
|
101
|
+
expect(checkAzCliAvailability()).toMatchObject({ available: false, reason: "not-found" });
|
|
102
|
+
|
|
103
|
+
spawnSyncMock.mockReturnValue({ status: 1, stdout: "", stderr: "ModuleNotFoundError: azure.cli" });
|
|
104
|
+
expect(checkAzCliAvailability()).toMatchObject({ available: false, reason: "not-runnable", detail: expect.stringContaining("ModuleNotFoundError") });
|
|
105
|
+
|
|
106
|
+
spawnSyncMock.mockReturnValue({ status: 0, stdout: "azure-cli 2.70.0", stderr: "" });
|
|
107
|
+
expect(checkAzCliAvailability()).toMatchObject({ available: true });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("provision planning with Azure CLI state", () => {
|
|
112
|
+
const originalCwd = process.cwd();
|
|
113
|
+
let project: string;
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
project = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-provision-az-"));
|
|
116
|
+
fs.mkdirSync(path.join(project, "infra"), { recursive: true });
|
|
117
|
+
fs.writeFileSync(path.join(project, "infra", "main.bicep"), "targetScope = 'resourceGroup'\n");
|
|
118
|
+
fs.writeFileSync(path.join(project, "infra", "main.parameters.json"), JSON.stringify({ parameters: { projectName: { value: "sample" } } }));
|
|
119
|
+
saveMigrationManifest(createMigrationManifest(project, { projectId: "project" }), project);
|
|
120
|
+
process.chdir(project);
|
|
121
|
+
setAzCliInvocationForTesting({ command: "az", prefixArgs: [], source: "az" });
|
|
122
|
+
jest.spyOn(azureCli, "runAzCliAsync").mockImplementation(async (args) => runAzCli(args));
|
|
123
|
+
});
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
process.chdir(originalCwd);
|
|
126
|
+
resetAzCliInvocationCache();
|
|
127
|
+
spawnSyncMock.mockReset();
|
|
128
|
+
spawnSyncMock.mockImplementation(jest.requireActual<typeof import("child_process")>("child_process").spawnSync);
|
|
129
|
+
fs.rmSync(project, { recursive: true, force: true });
|
|
130
|
+
jest.restoreAllMocks();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const options = { resourceGroup: "rg-sample", location: "japaneast", swaLocation: "eastasia", subscription: "11111111-1111-1111-1111-111111111111" };
|
|
134
|
+
|
|
135
|
+
it("reads Azure state through the resolved CLI and plans a bootstrap", async () => {
|
|
136
|
+
spawnSyncMock.mockImplementation((_command: string, args: string[]) => {
|
|
137
|
+
if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
|
|
138
|
+
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: "false\n", stderr: "" };
|
|
139
|
+
return { status: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
|
|
140
|
+
});
|
|
141
|
+
const plan = await planProvisionOperation(options);
|
|
142
|
+
expect(plan.azCliAvailable).toBe(true);
|
|
143
|
+
expect(plan.mode).toBe("bootstrap");
|
|
144
|
+
expect(plan.warnings.filter((warning) => warning.startsWith("az-"))).toEqual([]);
|
|
145
|
+
expect(spawnSyncMock.mock.calls.every((call: unknown[]) => (call[2] as { shell?: boolean }).shell === false)).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const plansDir = () => path.join(project, ".swallowkit", "state", "plans");
|
|
149
|
+
const savedPlans = () => (fs.existsSync(plansDir()) ? fs.readdirSync(plansDir()) : []);
|
|
150
|
+
|
|
151
|
+
it("keeps the interactive preview in memory and re-checks Azure before running it", async () => {
|
|
152
|
+
let groupExists = false;
|
|
153
|
+
spawnSyncMock.mockImplementation((_command: string, args: string[]) => {
|
|
154
|
+
if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
|
|
155
|
+
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: `${groupExists}\n`, stderr: "" };
|
|
156
|
+
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: `/subscriptions/${options.subscription}/resourceGroups/rg-sample`, tags: {} }), stderr: "" };
|
|
157
|
+
return { status: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
|
|
158
|
+
});
|
|
159
|
+
const preview = await planProvisionOperation(options, { persist: false });
|
|
160
|
+
expect(savedPlans()).toEqual([]);
|
|
161
|
+
expect(describeProvisionAction(preview)).toBe(
|
|
162
|
+
"Resource group rg-sample does not exist yet. It will be created in japaneast and the infrastructure in infra/main.bicep will be deployed."
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
groupExists = true;
|
|
166
|
+
await expect(executeProvisionPlan(preview, { approve: true, retryHint: INTERACTIVE_PROVISION_RETRY_HINT })).rejects.toMatchObject({
|
|
167
|
+
code: "stale-azure-state",
|
|
168
|
+
message: 'The resource group was created after the preview was computed. Run "swallowkit provision" again to review the current state.',
|
|
169
|
+
});
|
|
170
|
+
expect(spawnSyncMock.mock.calls.some((call: unknown[]) => (call[1] as string[])[0] === "group" && (call[1] as string[])[1] === "create")).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("still saves plans for the machine plan/apply workflow", async () => {
|
|
174
|
+
spawnSyncMock.mockImplementation((_command: string, args: string[]) => args[0] === "--version"
|
|
175
|
+
? { status: 0, stdout: "azure-cli", stderr: "" }
|
|
176
|
+
: { status: 0, stdout: "false\n", stderr: "" });
|
|
177
|
+
const plan = await planProvisionOperation(options);
|
|
178
|
+
expect(savedPlans().some((file) => file.includes(plan.planId))).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("asks for az login instead of reporting a missing CLI", async () => {
|
|
182
|
+
spawnSyncMock.mockImplementation((_command: string, args: string[]) => args[0] === "--version"
|
|
183
|
+
? { status: 0, stdout: "azure-cli", stderr: "" }
|
|
184
|
+
: { status: 1, stdout: "", stderr: "ERROR: Please run 'az login' to setup account." });
|
|
185
|
+
await expect(planProvisionOperation(options)).rejects.toMatchObject({ code: "az-not-logged-in", status: "requires-human" });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("reports an installed but broken CLI accurately", async () => {
|
|
189
|
+
spawnSyncMock.mockReturnValue({ status: 1, stdout: "", stderr: "Failed to load python executable." });
|
|
190
|
+
const plan = await planProvisionOperation(options);
|
|
191
|
+
expect(plan.mode).toBe("offline");
|
|
192
|
+
expect(plan.warnings.some((warning) => warning.startsWith("az-cli-not-runnable:") && warning.includes("Failed to load python executable."))).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("regular CLI error output", () => {
|
|
197
|
+
it("prints machine errors without a stack trace", () => {
|
|
198
|
+
const text = formatCliError(new MachineCommandError("online-plan-required", "Cannot apply.", { nextAction: "Run az login." }, "blocked"));
|
|
199
|
+
expect(text).toBe("❌ Cannot apply.\n (code: online-plan-required)\n Next: Run az login.");
|
|
200
|
+
expect(formatCliError(new Error("plain failure"))).toBe("❌ plain failure");
|
|
201
|
+
});
|
|
202
|
+
});
|