@intentius/chant 0.44.14 → 0.46.0
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/audit/discover.d.ts +26 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +10 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cli/build-params-cli.d.ts +16 -7
- package/dist/cli/build-params-cli.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +20 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +9 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
- package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +14 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
- package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
- package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts +28 -0
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +27 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-pages.d.ts +39 -0
- package/dist/codegen/docs-pages.d.ts.map +1 -0
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/codegen/docs-sidebar.d.ts +13 -2
- package/dist/codegen/docs-sidebar.d.ts.map +1 -1
- package/dist/codegen/docs-types.d.ts +30 -10
- package/dist/codegen/docs-types.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +14 -2
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.d.ts +11 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/surface-snapshot.d.ts +27 -3
- package/dist/codegen/surface-snapshot.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +21 -3
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/capability-plugin.d.ts +13 -2
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/driver.d.ts +38 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/config.d.ts +85 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/env.d.ts +12 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +100 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +12 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ops.d.ts +17 -0
- package/dist/graph-ops.d.ts.map +1 -0
- package/dist/lexicon.d.ts +182 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +23 -2
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +8 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +130 -0
- package/dist/lifecycle/teardown.d.ts.map +1 -0
- package/dist/lifecycle/types.d.ts +7 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lint/engine.d.ts +6 -2
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/lint/rule.d.ts +31 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
- package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +2 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +33 -21
- package/dist/managed-fields.d.ts.map +1 -1
- package/dist/observation.d.ts +23 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/builders.d.ts +36 -7
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/testing.d.ts +136 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/yaml.d.ts +0 -8
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/__snapshots__/okf.test.ts.snap +20 -0
- package/src/audit/discover.test.ts +55 -1
- package/src/audit/discover.ts +70 -2
- package/src/audit/edge-init-safety.test.ts +149 -18
- package/src/audit/report-model.ts +12 -1
- package/src/audit/rules-doc.ts +1 -0
- package/src/build-params.test.ts +52 -0
- package/src/cli/build-params-cli.test.ts +25 -8
- package/src/cli/build-params-cli.ts +37 -23
- package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
- package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
- package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
- package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
- package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
- package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
- package/src/cli/commands/audit.test.ts +83 -1
- package/src/cli/commands/audit.ts +121 -28
- package/src/cli/commands/build.test.ts +275 -5
- package/src/cli/commands/build.ts +86 -14
- package/src/cli/commands/check-lexicon-docs.ts +30 -0
- package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
- package/src/cli/commands/check-lexicon-examples.ts +66 -17
- package/src/cli/commands/check-lexicon.test.ts +46 -2
- package/src/cli/commands/check-lexicon.ts +64 -4
- package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
- package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
- package/src/cli/commands/init-lexicon.test.ts +42 -1
- package/src/cli/commands/init-lexicon.ts +3 -1
- package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
- package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
- package/src/cli/commands/lexicon-surface-diff.ts +10 -1
- package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
- package/src/cli/commands/lint.ts +19 -6
- package/src/cli/commands/onboard.test.ts +319 -225
- package/src/cli/commands/onboard.ts +144 -58
- package/src/cli/handlers/build.test.ts +3 -3
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.ts +2 -2
- package/src/cli/handlers/graph.test.ts +44 -0
- package/src/cli/handlers/graph.ts +24 -4
- package/src/cli/handlers/lifecycle.test.ts +348 -1
- package/src/cli/handlers/lifecycle.ts +235 -3
- package/src/cli/handlers/run.test.ts +5 -5
- package/src/cli/handlers/run.ts +1 -0
- package/src/cli/handlers/search-live.test.ts +217 -0
- package/src/cli/handlers/search.test.ts +41 -1
- package/src/cli/handlers/search.ts +60 -8
- package/src/cli/main.ts +17 -4
- package/src/cli/registry.ts +7 -0
- package/src/cli/security-boundary.test.ts +2 -1
- package/src/codegen/docs-pages.test.ts +194 -0
- package/src/codegen/docs-pages.ts +138 -0
- package/src/codegen/docs-sections.ts +0 -8
- package/src/codegen/docs-sidebar.ts +50 -37
- package/src/codegen/docs-types.ts +32 -4
- package/src/codegen/docs.ts +78 -40
- package/src/codegen/lexicon-regen.ts +22 -2
- package/src/codegen/rolling-upgrade.test.ts +1 -1
- package/src/codegen/rolling-upgrade.ts +1 -1
- package/src/codegen/surface-snapshot.test.ts +139 -0
- package/src/codegen/surface-snapshot.ts +297 -122
- package/src/codegen/validate.test.ts +49 -3
- package/src/codegen/validate.ts +36 -7
- package/src/components/auto-release.ts +1 -1
- package/src/components/capability-plugin.ts +23 -2
- package/src/components/driver.test.ts +45 -0
- package/src/components/driver.ts +64 -27
- package/src/components/index.ts +2 -0
- package/src/components/starter-plugin.ts +5 -2
- package/src/config.test.ts +40 -0
- package/src/config.ts +159 -8
- package/src/discovery/fold-import.test.ts +427 -2
- package/src/discovery/fold-import.ts +163 -7
- package/src/discovery/sandbox/driver.test.ts +11 -0
- package/src/env.test.ts +35 -1
- package/src/env.ts +17 -3
- package/src/fold/fold.test.ts +41 -0
- package/src/fold/fold.ts +372 -2
- package/src/graph-ir-live.test.ts +42 -0
- package/src/graph-ir.ts +42 -2
- package/src/graph-ops.test.ts +69 -0
- package/src/graph-ops.ts +42 -0
- package/src/lexicon.ts +182 -2
- package/src/lifecycle/change-set.test.ts +41 -0
- package/src/lifecycle/change-set.ts +30 -2
- package/src/lifecycle/deep-observe.ts +4 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/observe.ts +29 -2
- package/src/lifecycle/replay.ts +3 -0
- package/src/lifecycle/snapshot.ts +3 -0
- package/src/lifecycle/teardown.test.ts +537 -0
- package/src/lifecycle/teardown.ts +357 -0
- package/src/lifecycle/types.ts +7 -0
- package/src/lint/engine.ts +7 -1
- package/src/lint/policy.ts +14 -3
- package/src/lint/rule.ts +23 -0
- package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
- package/src/lint/rules/cor021-env-literal-name.ts +114 -0
- package/src/lint/rules/index.ts +4 -1
- package/src/managed-fields.test.ts +15 -42
- package/src/managed-fields.ts +43 -26
- package/src/observation.test.ts +24 -8
- package/src/observation.ts +48 -4
- package/src/op/builders.ts +40 -7
- package/src/op/index.ts +1 -1
- package/src/ownership.test.ts +63 -1
- package/src/testing.test.ts +261 -0
- package/src/testing.ts +338 -0
- package/src/yaml.test.ts +15 -0
- package/src/yaml.ts +19 -2
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { createMockPlugin } from "@intentius/chant-test-utils";
|
|
7
|
+
import { deployStack, testEnvName, TeardownIncompleteError } from "./testing";
|
|
8
|
+
import type { ActivityFn } from "./op/activity-registry";
|
|
9
|
+
import type { TeardownExecution } from "./lexicon";
|
|
10
|
+
|
|
11
|
+
/** A minimal deployable project: one declarable, mock lexicon, marker on. */
|
|
12
|
+
async function writeProject(
|
|
13
|
+
dir: string,
|
|
14
|
+
config: Record<string, unknown> = {},
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
await mkdir(dir, { recursive: true });
|
|
17
|
+
await writeFile(
|
|
18
|
+
join(dir, "chant.config.json"),
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
lexicons: ["mock"],
|
|
21
|
+
ownership: { stack: "harness-fixture" },
|
|
22
|
+
environments: ["dev", { name: "test-*", endpoint: "http://localhost:9999" }],
|
|
23
|
+
...config,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
await writeFile(
|
|
27
|
+
join(dir, "app.infra.ts"),
|
|
28
|
+
`export const bucket = {
|
|
29
|
+
lexicon: "mock",
|
|
30
|
+
entityType: "Mock::Bucket",
|
|
31
|
+
[Symbol.for("chant.declarable")]: true,
|
|
32
|
+
};
|
|
33
|
+
`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("testEnvName", () => {
|
|
38
|
+
test("prefixes test-, slugs the suite, appends a nonce", () => {
|
|
39
|
+
const name = testEnvName("My Fancy Suite!");
|
|
40
|
+
expect(name).toMatch(/^test-my-fancy-suite-[a-z0-9]{6}$/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("two calls never collide", () => {
|
|
44
|
+
expect(testEnvName("s")).not.toBe(testEnvName("s"));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("an all-symbol suite still produces a legal name", () => {
|
|
48
|
+
expect(testEnvName("!!!")).toMatch(/^test-suite-[a-z0-9]{6}$/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("deployStack", () => {
|
|
53
|
+
let dir: string;
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
dir = join(tmpdir(), `chant-testing-fixture-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await rm(dir, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const harness = (nativeApply: ActivityFn, overrides: Record<string, unknown> = {}) => ({
|
|
64
|
+
dir,
|
|
65
|
+
suite: "unit",
|
|
66
|
+
plugins: [createMockPlugin({ name: "mock" })],
|
|
67
|
+
activities: new Map([["nativeApply", nativeApply]]),
|
|
68
|
+
profiles: {},
|
|
69
|
+
applyTargets: { mock: "cloudformation" },
|
|
70
|
+
...overrides,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("deploy returns outputs, entities, and a nonce'd test env", async () => {
|
|
74
|
+
await writeProject(dir);
|
|
75
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => ({ applied: 1, pruned: 0, notAttempted: 0 }));
|
|
76
|
+
|
|
77
|
+
const stack = await deployStack(harness(nativeApply));
|
|
78
|
+
|
|
79
|
+
expect(stack.env).toMatch(/^test-unit-[a-z0-9]{6}$/);
|
|
80
|
+
expect(stack.entities.has("bucket")).toBe(true);
|
|
81
|
+
const output = stack.outputs.get("mock");
|
|
82
|
+
expect(output).toBeDefined();
|
|
83
|
+
const primary = typeof output === "string" ? output : output!.primary;
|
|
84
|
+
expect(JSON.parse(primary).resources.bucket).toEqual({ type: "Mock::Bucket" });
|
|
85
|
+
|
|
86
|
+
// The apply was additive, targeted at this env, over the written output.
|
|
87
|
+
expect(nativeApply).toHaveBeenCalledTimes(1);
|
|
88
|
+
const args = nativeApply.mock.calls[0][0] as Record<string, unknown>;
|
|
89
|
+
expect(args.target).toBe("cloudformation");
|
|
90
|
+
expect(args.env).toBe(stack.env);
|
|
91
|
+
expect(args.deleteMode).toBe("never");
|
|
92
|
+
expect(JSON.parse(readFileSync(args.output as string, "utf-8")).resources.bucket).toBeDefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("the declared test-* endpoint is applied during the deploy", async () => {
|
|
96
|
+
await writeProject(dir);
|
|
97
|
+
let seenDuringApply: string | undefined;
|
|
98
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => {
|
|
99
|
+
seenDuringApply = process.env.MOCK_ENDPOINT_URL;
|
|
100
|
+
return { applied: 1, pruned: 0, notAttempted: 0 };
|
|
101
|
+
});
|
|
102
|
+
const plugin = createMockPlugin({
|
|
103
|
+
name: "mock",
|
|
104
|
+
emulator: {
|
|
105
|
+
spec: { name: "chant-mock", image: "mock:1", containerPort: 1, healthPath: "/health" },
|
|
106
|
+
env: (endpoint: string) => ({ MOCK_ENDPOINT_URL: endpoint }),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
delete process.env.MOCK_ENDPOINT_URL;
|
|
111
|
+
await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
112
|
+
|
|
113
|
+
expect(seenDuringApply).toBe("http://localhost:9999");
|
|
114
|
+
// Scoped to the run — nothing leaks into the suite's own process env.
|
|
115
|
+
expect(process.env.MOCK_ENDPOINT_URL).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("an ambient endpoint var wins over the declared endpoint", async () => {
|
|
119
|
+
await writeProject(dir);
|
|
120
|
+
let seenDuringApply: string | undefined;
|
|
121
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => {
|
|
122
|
+
seenDuringApply = process.env.MOCK_ENDPOINT_URL;
|
|
123
|
+
return { applied: 1, pruned: 0, notAttempted: 0 };
|
|
124
|
+
});
|
|
125
|
+
const plugin = createMockPlugin({
|
|
126
|
+
name: "mock",
|
|
127
|
+
emulator: {
|
|
128
|
+
spec: { name: "chant-mock", image: "mock:1", containerPort: 1, healthPath: "/health" },
|
|
129
|
+
env: (endpoint: string) => ({ MOCK_ENDPOINT_URL: endpoint }),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
process.env.MOCK_ENDPOINT_URL = "http://ambient:1234";
|
|
134
|
+
try {
|
|
135
|
+
await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
136
|
+
} finally {
|
|
137
|
+
delete process.env.MOCK_ENDPOINT_URL;
|
|
138
|
+
}
|
|
139
|
+
expect(seenDuringApply).toBe("http://ambient:1234");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("destroy sweeps exactly the nonce'd env, marker-scoped", async () => {
|
|
143
|
+
await writeProject(dir);
|
|
144
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => ({ applied: 1, pruned: 0, notAttempted: 0 }));
|
|
145
|
+
const teardownOwned = vi.fn(async (args: { environment: string; marker: { stack: string; env?: string } }) => ({
|
|
146
|
+
candidates: [
|
|
147
|
+
{
|
|
148
|
+
name: "bucket",
|
|
149
|
+
type: "Mock::Bucket",
|
|
150
|
+
marker: { stack: args.marker.stack, env: args.marker.env! },
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
}));
|
|
154
|
+
const executeTeardown = vi.fn(
|
|
155
|
+
async (): Promise<TeardownExecution> => ({
|
|
156
|
+
outcomes: [{ name: "bucket", outcome: "deleted" }],
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
const plugin = createMockPlugin({ name: "mock", teardownOwned, executeTeardown });
|
|
160
|
+
|
|
161
|
+
const stack = await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
162
|
+
const report = await stack.destroy();
|
|
163
|
+
|
|
164
|
+
expect(teardownOwned).toHaveBeenCalledWith({
|
|
165
|
+
environment: stack.env,
|
|
166
|
+
marker: { stack: "harness-fixture", env: stack.env },
|
|
167
|
+
});
|
|
168
|
+
expect(report.environment).toBe(stack.env);
|
|
169
|
+
expect(report.stack).toBe("harness-fixture");
|
|
170
|
+
expect(report.outcomes).toEqual([
|
|
171
|
+
expect.objectContaining({ name: "bucket", outcome: "deleted" }),
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("teardown holes surface as a thrown TeardownIncompleteError", async () => {
|
|
176
|
+
await writeProject(dir);
|
|
177
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => ({ applied: 1, pruned: 0, notAttempted: 0 }));
|
|
178
|
+
const teardownOwned = vi.fn(async () => ({
|
|
179
|
+
candidates: [],
|
|
180
|
+
holes: [{ name: "bucket", reason: "read-failed" as const, detail: "connection refused" }],
|
|
181
|
+
}));
|
|
182
|
+
const plugin = createMockPlugin({ name: "mock", teardownOwned, executeTeardown: async () => ({ outcomes: [] }) });
|
|
183
|
+
|
|
184
|
+
const stack = await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
185
|
+
const err = await stack.destroy().then(
|
|
186
|
+
() => undefined,
|
|
187
|
+
(e: unknown) => e,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(err).toBeInstanceOf(TeardownIncompleteError);
|
|
191
|
+
expect((err as TeardownIncompleteError).message).toContain("hole");
|
|
192
|
+
expect((err as TeardownIncompleteError).report.plan.holes).toHaveLength(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a failed delete surfaces as a thrown TeardownIncompleteError", async () => {
|
|
196
|
+
await writeProject(dir);
|
|
197
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => ({ applied: 1, pruned: 0, notAttempted: 0 }));
|
|
198
|
+
const teardownOwned = vi.fn(async (args: { marker: { stack: string; env?: string } }) => ({
|
|
199
|
+
candidates: [{ name: "bucket", type: "Mock::Bucket", marker: { stack: args.marker.stack, env: args.marker.env! } }],
|
|
200
|
+
}));
|
|
201
|
+
const executeTeardown = vi.fn(
|
|
202
|
+
async (): Promise<TeardownExecution> => ({
|
|
203
|
+
outcomes: [{ name: "bucket", outcome: "failed", detail: "still in use" }],
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
const plugin = createMockPlugin({ name: "mock", teardownOwned, executeTeardown });
|
|
207
|
+
|
|
208
|
+
const stack = await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
209
|
+
const err = await stack.destroy().then(
|
|
210
|
+
() => undefined,
|
|
211
|
+
(e: unknown) => e,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(err).toBeInstanceOf(TeardownIncompleteError);
|
|
215
|
+
expect((err as TeardownIncompleteError).message).toContain("still in use");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a failed apply rejects with the local executor's failure", async () => {
|
|
219
|
+
await writeProject(dir);
|
|
220
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => {
|
|
221
|
+
throw new Error("emulator not reachable");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await expect(deployStack(harness(nativeApply, { profiles: { longInfra: { retry: { maximumAttempts: 1 } } } })))
|
|
225
|
+
.rejects.toThrow(/deploy-test-unit/);
|
|
226
|
+
expect(nativeApply).toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("an environment the project does not declare is refused with the test-* hint", async () => {
|
|
230
|
+
await writeProject(dir, { environments: ["dev", "prod"] });
|
|
231
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => ({ applied: 1, pruned: 0, notAttempted: 0 }));
|
|
232
|
+
|
|
233
|
+
await expect(deployStack(harness(nativeApply))).rejects.toThrow(/"test-\*" pattern/);
|
|
234
|
+
expect(nativeApply).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("an explicit env overrides the derived name", async () => {
|
|
238
|
+
await writeProject(dir);
|
|
239
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => ({ applied: 1, pruned: 0, notAttempted: 0 }));
|
|
240
|
+
|
|
241
|
+
const stack = await deployStack(harness(nativeApply, { env: "test-pinned" }));
|
|
242
|
+
|
|
243
|
+
expect(stack.env).toBe("test-pinned");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("a project without ownership.stack is refused before anything deploys", async () => {
|
|
247
|
+
await writeProject(dir, { ownership: undefined });
|
|
248
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => ({ applied: 1, pruned: 0, notAttempted: 0 }));
|
|
249
|
+
|
|
250
|
+
await expect(deployStack(harness(nativeApply))).rejects.toThrow(/ownership\.stack/);
|
|
251
|
+
expect(nativeApply).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a project whose outputs have no apply target is refused loudly", async () => {
|
|
255
|
+
await writeProject(dir);
|
|
256
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => ({ applied: 1, pruned: 0, notAttempted: 0 }));
|
|
257
|
+
|
|
258
|
+
await expect(deployStack(harness(nativeApply, { applyTargets: {} }))).rejects.toThrow(/nothing to deploy/);
|
|
259
|
+
expect(nativeApply).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
});
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@intentius/chant/testing` (#1224) — the live-stack test harness.
|
|
3
|
+
*
|
|
4
|
+
* A vitest suite deploys a real instance of its project once, asserts against
|
|
5
|
+
* it, and tears it down — emulator-locally on a dev machine, real-cloud in CI,
|
|
6
|
+
* with the same suite text. One call composes machinery that already exists:
|
|
7
|
+
*
|
|
8
|
+
* - **build** — the same programmatic path `chant build` takes: config
|
|
9
|
+
* resolution (`buildParams` + the ownership marker) mirrored from the CLI,
|
|
10
|
+
* then `build()` over the project's own serializers.
|
|
11
|
+
* - **apply** — an additive `nativeApply` per built lexicon output, run
|
|
12
|
+
* in-process through the local Op executor (`runOpLocally`). `deleteMode`
|
|
13
|
+
* is `"never"`: a test deploy creates and updates, nothing else.
|
|
14
|
+
* - **destroy** — #1222's marker-scoped teardown (`executeTeardown`), called
|
|
15
|
+
* in-process for exactly this suite's environment. Stateless by design:
|
|
16
|
+
* a crashed suite's environment is recovered by calling destroy again (or
|
|
17
|
+
* `chant lifecycle teardown <env> --yes`).
|
|
18
|
+
*
|
|
19
|
+
* ## Isolation
|
|
20
|
+
*
|
|
21
|
+
* Every deploy targets its own environment: `test-<suite>-<nonce>` by default
|
|
22
|
+
* ({@link testEnvName}), so parallel CI jobs never collide. The environment
|
|
23
|
+
* is the teardown key — everything the deploy stamps carries the ownership
|
|
24
|
+
* marker `{ stack, env }`, and `destroy()` selects on that identity and
|
|
25
|
+
* nothing else. A project that declares `environments` must legalize the
|
|
26
|
+
* dynamic names with a pattern entry (#1221):
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* environments: ["dev", "prod", { name: "test-*", endpoint: "http://localhost:4566" }]
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* ## Emulators
|
|
33
|
+
*
|
|
34
|
+
* The harness is emulator-aware the same way `--live` reads are (#1166): the
|
|
35
|
+
* target environment's declared `endpoint` is injected into each lexicon's
|
|
36
|
+
* ambient endpoint variable (`AWS_ENDPOINT_URL`, …) for the apply and the
|
|
37
|
+
* teardown — unless the variable is already set. Ambient always wins, so the
|
|
38
|
+
* env vars `chant emulator up --json` reports (#920) take precedence when
|
|
39
|
+
* exported, and a CI job pointing the identical suite at a real account just
|
|
40
|
+
* leaves both unset.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { basename, join, resolve } from "node:path";
|
|
44
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { tmpdir } from "node:os";
|
|
46
|
+
import { build } from "./build";
|
|
47
|
+
import type { Declarable } from "./declarable";
|
|
48
|
+
import type { SerializerResult } from "./serializer";
|
|
49
|
+
import {
|
|
50
|
+
loadChantConfigUpward,
|
|
51
|
+
resolveOwnershipStack,
|
|
52
|
+
type ChantConfig,
|
|
53
|
+
} from "./config";
|
|
54
|
+
import { resolveBuildParams, type BuildParamValue } from "./build-params";
|
|
55
|
+
import { ENV_VAR, unknownEnvError } from "./env";
|
|
56
|
+
import { applyLiveEndpoint } from "./live-endpoint";
|
|
57
|
+
import { loadPlugins, resolveProjectLexicons, collectBuildRootContributors } from "./cli/plugins";
|
|
58
|
+
import type { LexiconPlugin } from "./lexicon";
|
|
59
|
+
import type { OwnershipMarker } from "./ownership";
|
|
60
|
+
import { runOpLocally } from "./op/local-executor";
|
|
61
|
+
import { loadActivities, loadProfiles, type ActivityFn, type ActivityProfile } from "./op/activity-registry";
|
|
62
|
+
import type { OpConfig, ActivityStep } from "./op/types";
|
|
63
|
+
import { executeTeardown, type TeardownReport } from "./lifecycle/teardown";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Which `nativeApply` target deploys each lexicon's built output. Only these
|
|
67
|
+
* lexicons have a native apply mechanism; an output from any other lexicon
|
|
68
|
+
* (helm values, a CI pipeline file, …) is returned to the caller but not
|
|
69
|
+
* applied — deploying it is not what a live-stack test means by "deploy".
|
|
70
|
+
*/
|
|
71
|
+
const APPLY_TARGETS: Record<string, string> = {
|
|
72
|
+
aws: "cloudformation",
|
|
73
|
+
k8s: "kubectl",
|
|
74
|
+
azure: "arm",
|
|
75
|
+
gcp: "gcp",
|
|
76
|
+
fly: "fly",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export interface DeployStackOptions {
|
|
80
|
+
/** The chant project directory (where the infra source lives). */
|
|
81
|
+
dir: string;
|
|
82
|
+
/**
|
|
83
|
+
* Explicit environment name. Overrides the default `test-<suite>-<nonce>`
|
|
84
|
+
* derivation — for a shared long-lived test environment, or a test that
|
|
85
|
+
* needs a deterministic name. Must be legal for the project's declared
|
|
86
|
+
* `environments`, exactly as `--env` would be.
|
|
87
|
+
*/
|
|
88
|
+
env?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Suite name folded into the default environment name. Defaults to the
|
|
91
|
+
* project directory's basename.
|
|
92
|
+
*/
|
|
93
|
+
suite?: string;
|
|
94
|
+
/** Build parameters, exactly as `--param name=value` flags would supply them. */
|
|
95
|
+
params?: Record<string, BuildParamValue>;
|
|
96
|
+
/**
|
|
97
|
+
* Loaded lexicon plugins. Defaults to loading the project's own declared/
|
|
98
|
+
* detected lexicons — pass this only to substitute test doubles.
|
|
99
|
+
*/
|
|
100
|
+
plugins?: LexiconPlugin[];
|
|
101
|
+
/** Activity implementations for the apply. Defaults to the real registry. */
|
|
102
|
+
activities?: Map<string, ActivityFn>;
|
|
103
|
+
/** Activity profiles (timeouts/retries). Defaults to the lexicon-declared table. */
|
|
104
|
+
profiles?: Record<string, ActivityProfile>;
|
|
105
|
+
/**
|
|
106
|
+
* Extra lexicon-name → `nativeApply` target entries, merged over the
|
|
107
|
+
* built-in map (aws → cloudformation, k8s → kubectl, azure → arm,
|
|
108
|
+
* gcp → gcp, fly → fly). A seam for tests and out-of-tree lexicons.
|
|
109
|
+
*/
|
|
110
|
+
applyTargets?: Record<string, string>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The handle a suite holds between `beforeAll` and `afterAll`. */
|
|
114
|
+
export interface DeployedStack {
|
|
115
|
+
/** The built outputs, keyed by lexicon — what was deployed. */
|
|
116
|
+
outputs: Map<string, string | SerializerResult>;
|
|
117
|
+
/** The discovered entities, keyed by name. */
|
|
118
|
+
entities: Map<string, Declarable>;
|
|
119
|
+
/** The environment this deploy targeted — the teardown key. */
|
|
120
|
+
env: string;
|
|
121
|
+
/**
|
|
122
|
+
* Tear down everything carrying this suite's marker `{ stack, env }`.
|
|
123
|
+
* Throws {@link TeardownIncompleteError} when any candidate failed to
|
|
124
|
+
* delete or the plan had holes — an environment that cannot be called
|
|
125
|
+
* clean is a test failure, never a silent leak. Safe to call again:
|
|
126
|
+
* teardown is stateless, and a second call over a clean environment
|
|
127
|
+
* plans nothing.
|
|
128
|
+
*/
|
|
129
|
+
destroy(): Promise<TeardownReport>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Thrown by {@link DeployedStack.destroy} when the environment cannot be
|
|
134
|
+
* called clean: candidates still `failed` after the retry pass, or the plan
|
|
135
|
+
* had holes (#1089 — parts of the estate could not be read, so "nothing
|
|
136
|
+
* left" would be a claim about what was readable, not about the environment).
|
|
137
|
+
* Carries the full report for diagnosis.
|
|
138
|
+
*/
|
|
139
|
+
export class TeardownIncompleteError extends Error {
|
|
140
|
+
constructor(public readonly report: TeardownReport) {
|
|
141
|
+
const failed = report.outcomes.filter((o) => o.outcome === "failed");
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
if (failed.length > 0) {
|
|
144
|
+
parts.push(
|
|
145
|
+
`${failed.length} candidate(s) failed to delete: ` +
|
|
146
|
+
failed.map((o) => `${o.lexicon}/${o.name}${o.detail ? ` (${o.detail})` : ""}`).join(", "),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (report.plan.holes.length > 0) {
|
|
150
|
+
parts.push(
|
|
151
|
+
`${report.plan.holes.length} hole(s) — resources chant may own but could not read: ` +
|
|
152
|
+
report.plan.holes.map((h) => `${h.lexicon}/${h.name} (${h.reason})`).join(", "),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
super(`teardown of "${report.environment}" is incomplete — ${parts.join("; ")}`);
|
|
156
|
+
this.name = "TeardownIncompleteError";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The default environment name for one suite run: `test-<suite>-<nonce>`.
|
|
162
|
+
* The `test-` prefix is what a project's `"test-*"` pattern entry (#1221)
|
|
163
|
+
* legalizes; the nonce keeps parallel CI jobs of the same suite apart.
|
|
164
|
+
*/
|
|
165
|
+
export function testEnvName(suite: string): string {
|
|
166
|
+
const slug =
|
|
167
|
+
suite
|
|
168
|
+
.toLowerCase()
|
|
169
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
170
|
+
.replace(/^-+|-+$/g, "")
|
|
171
|
+
.slice(0, 24)
|
|
172
|
+
.replace(/-+$/, "") || "suite";
|
|
173
|
+
const nonce = Math.random().toString(36).slice(2, 8).padEnd(6, "0");
|
|
174
|
+
return `test-${slug}-${nonce}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** JSON output starts with a brace/bracket; everything else is written as YAML. */
|
|
178
|
+
function outputExtension(content: string): string {
|
|
179
|
+
const head = content.trimStart();
|
|
180
|
+
return head.startsWith("{") || head.startsWith("[") ? ".json" : ".yaml";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Write each lexicon's built output to a scratch directory and return the
|
|
185
|
+
* primary file path per lexicon. Secondary files (a multi-file serializer
|
|
186
|
+
* result) land beside the primary under their own names.
|
|
187
|
+
*/
|
|
188
|
+
function writeOutputs(outputs: Map<string, string | SerializerResult>, env: string): Map<string, string> {
|
|
189
|
+
const dir = mkdtempSync(join(tmpdir(), `chant-testing-${env}-`));
|
|
190
|
+
const paths = new Map<string, string>();
|
|
191
|
+
for (const [lexicon, output] of outputs) {
|
|
192
|
+
const primary = typeof output === "string" ? output : output.primary;
|
|
193
|
+
const path = join(dir, `${lexicon}${outputExtension(primary)}`);
|
|
194
|
+
writeFileSync(path, primary);
|
|
195
|
+
if (typeof output !== "string" && output.files) {
|
|
196
|
+
for (const [name, content] of Object.entries(output.files)) {
|
|
197
|
+
writeFileSync(join(dir, basename(name)), content);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
paths.set(lexicon, path);
|
|
201
|
+
}
|
|
202
|
+
return paths;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Deploy a live instance of the project in `dir` for one test suite.
|
|
207
|
+
*
|
|
208
|
+
* Build (the CLI's own config resolution: `buildParams`, ownership marker,
|
|
209
|
+
* build roots) + additive apply (one `nativeApply` per built output with a
|
|
210
|
+
* native mechanism, via the local Op executor). Returns the handle the suite
|
|
211
|
+
* holds: outputs, entities, the environment name, and `destroy`.
|
|
212
|
+
*
|
|
213
|
+
* The ownership marker stamped on every resource is `{ stack: ownership.stack,
|
|
214
|
+
* env: <this deploy's environment> }` — the marker env always follows the
|
|
215
|
+
* suite environment, whatever `ownership.env` in config says, because the
|
|
216
|
+
* marker is the only thing `destroy()` selects on. A project with no
|
|
217
|
+
* `ownership.stack` is refused up front: a deploy that stamps nothing is a
|
|
218
|
+
* deploy nothing can sweep.
|
|
219
|
+
*
|
|
220
|
+
* Failures are thrown, never returned: build errors, unresolved parameters,
|
|
221
|
+
* an illegal environment name, and a failed apply (the local executor's
|
|
222
|
+
* `OpRunFailure`) all reject the returned promise.
|
|
223
|
+
*/
|
|
224
|
+
export async function deployStack(options: DeployStackOptions): Promise<DeployedStack> {
|
|
225
|
+
const dir = resolve(options.dir);
|
|
226
|
+
const { config, configPath } = await loadChantConfigUpward(dir);
|
|
227
|
+
|
|
228
|
+
const env = options.env ?? testEnvName(options.suite ?? basename(dir));
|
|
229
|
+
const envError = unknownEnvError(env, config.environments);
|
|
230
|
+
if (envError) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`${envError} The harness derives per-run environment names — declare a "test-*" pattern entry ` +
|
|
233
|
+
`in chant.config.ts's environments to legalize them (#1221).`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const stack = resolveOwnershipStack(config);
|
|
238
|
+
if (stack === undefined) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`${dir}: this project declares no ownership.stack — the harness's destroy() is marker-scoped ` +
|
|
241
|
+
`and would have nothing to select on. Set ownership: { stack: "<name>" } in chant.config.ts.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
const marker: OwnershipMarker = { stack, env };
|
|
245
|
+
|
|
246
|
+
const plugins = options.plugins ?? (await loadPlugins(await resolveProjectLexicons(dir)));
|
|
247
|
+
|
|
248
|
+
// Build parameters, resolved as `chant build --param ...` resolves them.
|
|
249
|
+
// A project whose config maps `env` to a build parameter gets this deploy's
|
|
250
|
+
// environment automatically, unless the caller passed one explicitly.
|
|
251
|
+
const cli: Record<string, string> = {};
|
|
252
|
+
for (const [name, value] of Object.entries(options.params ?? {})) cli[name] = String(value);
|
|
253
|
+
if (config.buildParams && "env" in config.buildParams && !("env" in cli)) cli.env = env;
|
|
254
|
+
const resolution = resolveBuildParams(config.buildParams, { cli, env: process.env });
|
|
255
|
+
if (resolution.errors.length > 0) {
|
|
256
|
+
throw new Error(`${dir}: build parameters did not resolve —\n ${resolution.errors.join("\n ")}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const projectRoot = configPath ? resolve(configPath, "..") : dir;
|
|
260
|
+
const configRecord = config as unknown as Record<string, unknown>;
|
|
261
|
+
|
|
262
|
+
// Env-aware source branches on env() (#505) during discovery — scope the
|
|
263
|
+
// variable to the build and restore whatever the shell had.
|
|
264
|
+
const priorEnv = process.env[ENV_VAR];
|
|
265
|
+
let result;
|
|
266
|
+
try {
|
|
267
|
+
process.env[ENV_VAR] = env;
|
|
268
|
+
result = await build(dir, plugins.map((p) => p.serializer), undefined, {
|
|
269
|
+
ownership: marker,
|
|
270
|
+
config: configRecord,
|
|
271
|
+
lexicons: plugins.map((p) => p.name),
|
|
272
|
+
intrinsics: plugins.flatMap((p) => p.intrinsics?.() ?? []),
|
|
273
|
+
buildParams: resolution.provenance,
|
|
274
|
+
buildRoots: collectBuildRootContributors(plugins, configRecord, projectRoot),
|
|
275
|
+
});
|
|
276
|
+
} finally {
|
|
277
|
+
if (priorEnv === undefined) delete process.env[ENV_VAR];
|
|
278
|
+
else process.env[ENV_VAR] = priorEnv;
|
|
279
|
+
}
|
|
280
|
+
if (result.errors.length > 0) {
|
|
281
|
+
const messages = result.errors.map((e) => e.message);
|
|
282
|
+
throw new Error(`${dir}: build failed —\n ${messages.join("\n ")}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// One additive apply step per built output with a native mechanism.
|
|
286
|
+
const targets = { ...APPLY_TARGETS, ...options.applyTargets };
|
|
287
|
+
const paths = writeOutputs(result.outputs, env);
|
|
288
|
+
const steps: ActivityStep[] = [];
|
|
289
|
+
for (const [lexicon, path] of paths) {
|
|
290
|
+
const target = targets[lexicon];
|
|
291
|
+
if (!target) continue;
|
|
292
|
+
steps.push({
|
|
293
|
+
kind: "activity",
|
|
294
|
+
fn: "nativeApply",
|
|
295
|
+
args: { target, env, output: path, deleteMode: "never" },
|
|
296
|
+
profile: "longInfra",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (steps.length === 0) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`${dir}: nothing to deploy — no built output has a native apply target ` +
|
|
302
|
+
`(built: ${[...result.outputs.keys()].join(", ") || "none"}; targets exist for: ${Object.keys(targets).join(", ")}).`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const op: OpConfig = {
|
|
306
|
+
name: `deploy-${env}`,
|
|
307
|
+
overview: `test harness deploy of ${dir} into ${env}`,
|
|
308
|
+
phases: [{ name: "Deploy", steps }],
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const activities = options.activities ?? (await loadActivities(plugins.map((p) => p.name)));
|
|
312
|
+
const profiles = options.profiles ?? (await loadProfiles());
|
|
313
|
+
|
|
314
|
+
// The environment's declared endpoint (#1166) applies to the deploy the way
|
|
315
|
+
// it applies to a --live read — and ambient always wins, so exported
|
|
316
|
+
// `chant emulator up --json` vars take precedence.
|
|
317
|
+
const applied = applyLiveEndpoint(config.environments, env, plugins);
|
|
318
|
+
try {
|
|
319
|
+
await runOpLocally(op, activities, profiles);
|
|
320
|
+
} finally {
|
|
321
|
+
applied.restore();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const destroy = async (): Promise<TeardownReport> => {
|
|
325
|
+
const endpointForTeardown = applyLiveEndpoint(config.environments, env, plugins);
|
|
326
|
+
let report: TeardownReport;
|
|
327
|
+
try {
|
|
328
|
+
report = await executeTeardown({ environment: env, stack, plugins });
|
|
329
|
+
} finally {
|
|
330
|
+
endpointForTeardown.restore();
|
|
331
|
+
}
|
|
332
|
+
const failed = report.outcomes.some((o) => o.outcome === "failed");
|
|
333
|
+
if (failed || report.plan.holes.length > 0) throw new TeardownIncompleteError(report);
|
|
334
|
+
return report;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return { outputs: result.outputs, entities: result.entities, env, destroy };
|
|
338
|
+
}
|
package/src/yaml.test.ts
CHANGED
|
@@ -10,6 +10,21 @@ describe("emitYAML", () => {
|
|
|
10
10
|
expect(emitYAML(undefined, 0)).toBe("null");
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
test("an undefined object value drops the key, an explicit null keeps it (#1371)", () => {
|
|
14
|
+
// Matches `JSON.stringify`: `{ a: undefined }` is `{}`; an unset optional
|
|
15
|
+
// build parameter passed straight into a resource must not ship as
|
|
16
|
+
// `a: null`.
|
|
17
|
+
expect(emitYAML({ a: undefined, b: 1 }, 0)).toBe("\nb: 1");
|
|
18
|
+
expect(emitYAML({ a: null, b: 1 }, 0)).toBe("\na: null\nb: 1");
|
|
19
|
+
expect(emitYAML({ a: undefined }, 0)).toBe("{}");
|
|
20
|
+
expect(emitYAML({ spec: { baseImageArn: undefined, name: "x" } }, 0)).toBe("\nspec:\n name: x");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("an undefined inside an array-item object drops the key; a bare undefined element is null", () => {
|
|
24
|
+
expect(emitYAML([{ a: undefined, b: 1 }, { c: undefined, d: 2, e: 3 }], 0)).toBe("\n- b: 1\n- d: 2\n e: 3");
|
|
25
|
+
expect(emitYAML([undefined, 1], 0)).toBe("\n- null\n- 1");
|
|
26
|
+
});
|
|
27
|
+
|
|
13
28
|
test("booleans", () => {
|
|
14
29
|
expect(emitYAML(true, 0)).toBe("true");
|
|
15
30
|
expect(emitYAML(false, 0)).toBe("false");
|
package/src/yaml.ts
CHANGED
|
@@ -19,9 +19,26 @@
|
|
|
19
19
|
* with `\n` so the caller can append it after a key.
|
|
20
20
|
* - Tagged values `{ tag, value }` emit `!tag [...]` or `!tag scalar`.
|
|
21
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* An object's entries minus those whose value is `undefined`. An `undefined`
|
|
24
|
+
* property is "not supplied" in TypeScript (a declared-but-unset optional
|
|
25
|
+
* build parameter, `{ x: cond ? v : undefined }`), which `JSON.stringify`
|
|
26
|
+
* already omits; the YAML emitter must agree, or the same source ships
|
|
27
|
+
* `key: null` under `-o out.yaml` and no key at all under `-o out.json`. An
|
|
28
|
+
* explicit `null` is a value and is kept (chant #1371).
|
|
29
|
+
*/
|
|
30
|
+
function definedEntries(obj: Record<string, unknown>): [string, unknown][] {
|
|
31
|
+
return Object.entries(obj).filter(([, val]) => val !== undefined);
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
export function emitYAML(value: unknown, indent: number): string {
|
|
23
35
|
const prefix = " ".repeat(indent);
|
|
24
36
|
|
|
37
|
+
// `undefined` only reaches here as a top-level value or an array element —
|
|
38
|
+
// an object entry whose value is `undefined` is dropped below, the same way
|
|
39
|
+
// `JSON.stringify` omits it. A bare `undefined` renders as `null` for the
|
|
40
|
+
// same reason `JSON.stringify([undefined])` is `[null]`: YAML has no way to
|
|
41
|
+
// say "absent" in a sequence slot.
|
|
25
42
|
if (value === null || value === undefined) {
|
|
26
43
|
return "null";
|
|
27
44
|
}
|
|
@@ -74,7 +91,7 @@ export function emitYAML(value: unknown, indent: number): string {
|
|
|
74
91
|
for (const item of value) {
|
|
75
92
|
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
76
93
|
// Object items in arrays
|
|
77
|
-
const entries =
|
|
94
|
+
const entries = definedEntries(item as Record<string, unknown>);
|
|
78
95
|
if (entries.length > 0) {
|
|
79
96
|
const [firstKey, firstVal] = entries[0];
|
|
80
97
|
const firstEmitted = emitYAML(firstVal, indent + 2);
|
|
@@ -111,7 +128,7 @@ export function emitYAML(value: unknown, indent: number): string {
|
|
|
111
128
|
return `${obj.tag} ${emitYAML(obj.value, indent)}`;
|
|
112
129
|
}
|
|
113
130
|
|
|
114
|
-
const entries =
|
|
131
|
+
const entries = definedEntries(obj);
|
|
115
132
|
if (entries.length === 0) return "{}";
|
|
116
133
|
const lines: string[] = [];
|
|
117
134
|
for (const [key, val] of entries) {
|