miravo 0.4.0 → 0.6.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/README.md +1 -1
- package/console-dist/assets/activity-DsWeRq4h.js +1 -0
- package/console-dist/assets/api-4AqKvsm9.js +1 -0
- package/console-dist/assets/arrow-right-Ea_UhTNi.js +1 -0
- package/console-dist/assets/arrow-up-down-DFSjzzl2.js +1 -0
- package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +1 -0
- package/console-dist/assets/box-C1WaoOMF.js +1 -0
- package/console-dist/assets/card-BjeP32PN.js +1 -0
- package/console-dist/assets/catalog-BJzyzbLR.js +1 -0
- package/console-dist/assets/catalog-DKjEm-c4.js +1 -0
- package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +1 -0
- package/console-dist/assets/checkbox-DZlJXTE4.js +1 -0
- package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +3 -0
- package/console-dist/assets/combobox-BGnuCYxO.js +1 -0
- package/console-dist/assets/control-CPhvwx9o.js +1 -0
- package/console-dist/assets/control-Cg2up9NA.js +1 -0
- package/console-dist/assets/copy-button-WkbNkMcd.js +1 -0
- package/console-dist/assets/createLucideIcon-Bwxw859K.js +1 -0
- package/console-dist/assets/deploy-tab-JeWDwPoZ.js +1 -0
- package/console-dist/assets/dist-BhbDML0a.js +1 -0
- package/console-dist/assets/dist-Cy8XTNFT.js +2 -0
- package/console-dist/assets/download-DQ6NH16U.js +1 -0
- package/console-dist/assets/engine-store-CrLnz692.js +1 -0
- package/console-dist/assets/events-CXm29Ok4.js +1 -0
- package/console-dist/assets/events-D01IxhnP.js +1 -0
- package/console-dist/assets/fleet-CfxbKXt9.js +1 -0
- package/console-dist/assets/fleet-lotMGVqe.js +1 -0
- package/console-dist/assets/format-kXTonpDJ.js +1 -0
- package/console-dist/assets/gauge-Ci8zNZlk.js +1 -0
- package/console-dist/assets/get-error-message-3Q5Q-2G5.js +1 -0
- package/console-dist/assets/index-CVPKSDvJ.js +12 -0
- package/console-dist/assets/index-DJkgDeun.css +2 -0
- package/console-dist/assets/input-BWEQFptx.js +1 -0
- package/console-dist/assets/instance-detail-C5IVwuh3.js +1 -0
- package/console-dist/assets/label-uephX5ZG.js +1 -0
- package/console-dist/assets/layers-CUsLQ1fq.js +1 -0
- package/console-dist/assets/login-C3RJli0m.js +126 -0
- package/console-dist/assets/models-B_UioWQ5.js +1 -0
- package/console-dist/assets/models-_IPlPiZ7.js +1 -0
- package/console-dist/assets/not-found-B8gAm08S.js +1 -0
- package/console-dist/assets/platform-targets-R3T-4eJ7.js +1 -0
- package/console-dist/assets/platforms-BWh0BQ3Y.js +1 -0
- package/console-dist/assets/platforms-DStNbtVc.js +1 -0
- package/console-dist/assets/proxy-CsnYqnPp.js +9 -0
- package/console-dist/assets/query-keys-BvtE7BMs.js +1 -0
- package/console-dist/assets/radio-CoHegOv5.js +1 -0
- package/console-dist/assets/rolldown-runtime-COnpUsM8.js +1 -0
- package/console-dist/assets/select-CeO4vP0G.js +1 -0
- package/console-dist/assets/select-display-items-CIzY8953.js +1 -0
- package/console-dist/assets/server-D9Kfn8fQ.js +1 -0
- package/console-dist/assets/shallow-6lGjQhtJ.js +1 -0
- package/console-dist/assets/tags-CiXOIHtY.js +4 -0
- package/console-dist/assets/tags-FQCXIxrP.js +1 -0
- package/console-dist/assets/tooltip-HGLF366Q.js +1 -0
- package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +1 -0
- package/console-dist/assets/transition-types-CPlGtM8w.js +1 -0
- package/console-dist/assets/triangle-alert-adNk_sea.js +1 -0
- package/console-dist/assets/use-platform-stream-DGbK1k2L.js +2 -0
- package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +2 -0
- package/console-dist/assets/validation-Blvud9_O.js +1 -0
- package/console-dist/assets/vendor-charts-CAZdaPlq.js +60 -0
- package/console-dist/assets/vendor-table-wd8JuMmT.js +4 -0
- package/console-dist/assets/vendor-ui-ByahnaY0.js +1 -0
- package/console-dist/index.html +70 -2
- package/package.json +9 -8
- package/src/__tests__/integration/executable-methods-runner.ts +7 -6
- package/src/__tests__/integration/executable-methods.integration.ts +214 -122
- package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +314 -0
- package/src/commands/adapter.ts +180 -7
- package/src/commands/apply.test.ts +62 -0
- package/src/commands/apply.ts +216 -0
- package/src/commands/cleanup.test.ts +34 -0
- package/src/commands/cleanup.ts +201 -0
- package/src/commands/export.test.ts +51 -0
- package/src/commands/export.ts +404 -0
- package/src/commands/fork.ts +2 -2
- package/src/commands/inject.ts +2 -2
- package/src/commands/load.ts +1 -1
- package/src/commands/logs.ts +13 -15
- package/src/commands/models.test.ts +1 -1
- package/src/commands/models.ts +1 -1
- package/src/commands/pause.ts +3 -3
- package/src/commands/platforms.test.ts +186 -0
- package/src/commands/platforms.ts +371 -0
- package/src/commands/remove.ts +1 -1
- package/src/commands/reset.ts +2 -2
- package/src/commands/spawn.ts +2 -2
- package/src/commands/speed.ts +2 -2
- package/src/commands/start/adapter-config.ts +157 -0
- package/src/commands/start/catalog-le-dt.test.ts +50 -0
- package/src/commands/start/catalog-le-dt.ts +187 -0
- package/src/commands/start/catalog-model-schema.ts +89 -0
- package/src/commands/start/catalog-template-detail.ts +54 -0
- package/src/commands/start/daemon.ts +255 -0
- package/src/commands/start/display.ts +150 -0
- package/src/commands/start/engine-setup.ts +247 -0
- package/src/commands/start/events.ts +126 -0
- package/src/commands/start/index.ts +897 -0
- package/src/commands/start/protocol-plans.ts +239 -0
- package/src/commands/start/template-loader.ts +197 -0
- package/src/commands/start/template-resolver.ts +185 -0
- package/src/commands/start/types.ts +73 -0
- package/src/commands/start.test.ts +137 -16
- package/src/commands/status.ts +1 -1
- package/src/commands/stop.ts +19 -8
- package/src/commands/templates.test.ts +1 -1
- package/src/commands/templates.ts +1 -3
- package/src/index.ts +11 -3
- package/src/lib/admin-auth.ts +157 -0
- package/src/lib/admin-client.test.ts +31 -15
- package/src/lib/admin-client.ts +106 -6
- package/src/lib/admin-platform-routes.ts +1017 -0
- package/src/lib/admin-platform-stream.test.ts +434 -0
- package/src/lib/admin-platform-stream.ts +788 -0
- package/src/lib/admin-responses.ts +211 -0
- package/src/lib/admin-server.test.ts +274 -55
- package/src/lib/admin-server.ts +387 -248
- package/src/lib/auth-store.ts +87 -0
- package/src/lib/daemon-registry.ts +12 -14
- package/src/lib/le/le-apply.test.ts +591 -0
- package/src/lib/le/le-apply.ts +832 -0
- package/src/lib/le/le-auth.ts +89 -0
- package/src/lib/le/le-cleanup.test.ts +168 -0
- package/src/lib/le/le-cleanup.ts +399 -0
- package/src/lib/le/le-cli-helpers.ts +77 -0
- package/src/lib/le/le-client.ts +184 -0
- package/src/lib/le/le-concurrency.test.ts +157 -0
- package/src/lib/le/le-concurrency.ts +78 -0
- package/src/lib/le/le-connection-test.ts +42 -0
- package/src/lib/le/le-devicehub-properties.test.ts +53 -0
- package/src/lib/le/le-devicehub-properties.ts +33 -0
- package/src/lib/le/le-devicehub.ts +213 -0
- package/src/lib/le/le-digital-twins.ts +285 -0
- package/src/lib/le/le-drivers.ts +23 -0
- package/src/lib/le/le-endpoint.ts +40 -0
- package/src/lib/le/le-environments.test.ts +73 -0
- package/src/lib/le/le-environments.ts +156 -0
- package/src/lib/le/le-markers.ts +87 -0
- package/src/lib/le/le-plan.test.ts +26 -0
- package/src/lib/le/le-plan.ts +109 -0
- package/src/lib/le/le-platform-operations.test.ts +198 -0
- package/src/lib/le/le-platform-operations.ts +340 -0
- package/src/lib/le/le-static-model-schema.ts +78 -0
- package/src/lib/le/le-static-planner.test.ts +255 -0
- package/src/lib/le/le-static-planner.ts +442 -0
- package/src/lib/le/le-template-exporter.test.ts +527 -0
- package/src/lib/le/le-template-exporter.ts +459 -0
- package/src/lib/le/le-value-types.ts +23 -0
- package/src/lib/mqtt-client.ts +1 -2
- package/src/lib/resolve-simulation.ts +1 -6
- package/src/lib/session-store.ts +110 -0
- package/src/lib/shutdown.test.ts +184 -2
- package/src/lib/shutdown.ts +104 -34
- package/src/lib/template-category.ts +3 -3
- package/src/lib/template-locator.ts +8 -2
- package/src/lib/ws-hub.ts +72 -46
- package/src/logger.ts +39 -14
- package/src/output.ts +3 -0
- package/console-dist/assets/index-C3XMYq26.css +0 -2
- package/console-dist/assets/index-hUJSpKtB.js +0 -119
- package/src/commands/start.ts +0 -2023
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
import type { EventLog } from "@miravo/core";
|
|
3
|
+
import * as adminPlatformRoutes from "./admin-platform-routes.ts";
|
|
4
|
+
import { handleApplyStream, handleCleanupStream } from "./admin-platform-stream.ts";
|
|
5
|
+
import * as leApply from "./le/le-apply.ts";
|
|
6
|
+
import * as leCleanup from "./le/le-cleanup.ts";
|
|
7
|
+
import { LEClient } from "./le/le-client.ts";
|
|
8
|
+
import type { LEDeviceHub } from "./le/le-devicehub.ts";
|
|
9
|
+
import type { LEDigitalTwins } from "./le/le-digital-twins.ts";
|
|
10
|
+
import type { LEEnvironment } from "./le/le-environments.ts";
|
|
11
|
+
import * as leEnvironments from "./le/le-environments.ts";
|
|
12
|
+
import type { ApplyPlan, PlanEntry } from "./le/le-plan.ts";
|
|
13
|
+
import type { PlatformOperationRecord } from "./le/le-platform-operations.ts";
|
|
14
|
+
import * as leOperations from "./le/le-platform-operations.ts";
|
|
15
|
+
|
|
16
|
+
type ApplyContext = {
|
|
17
|
+
readonly req: {
|
|
18
|
+
readonly environmentId: string;
|
|
19
|
+
readonly template?: string;
|
|
20
|
+
readonly model?: string;
|
|
21
|
+
readonly area?: string;
|
|
22
|
+
readonly line?: string;
|
|
23
|
+
readonly instance?: string;
|
|
24
|
+
readonly ifUnmanaged?: "fail" | "skip";
|
|
25
|
+
readonly scope?: readonly string[];
|
|
26
|
+
};
|
|
27
|
+
readonly env: LEEnvironment;
|
|
28
|
+
readonly templateId: string;
|
|
29
|
+
readonly hub: LEDeviceHub;
|
|
30
|
+
readonly dt: LEDigitalTwins;
|
|
31
|
+
readonly desired: {
|
|
32
|
+
readonly devices: readonly unknown[];
|
|
33
|
+
readonly models: readonly unknown[];
|
|
34
|
+
readonly instances: readonly unknown[];
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type TrackerSummary = {
|
|
39
|
+
readonly successCount: number;
|
|
40
|
+
readonly failedCount: number;
|
|
41
|
+
readonly blockedCount: number;
|
|
42
|
+
readonly progressCount: number;
|
|
43
|
+
readonly error?: string | undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type ParsedSSEEvent = {
|
|
47
|
+
readonly event: string;
|
|
48
|
+
readonly data: Record<string, unknown>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const APPLY_PLAN_ATTEMPTS = [
|
|
52
|
+
createPlan("le-host", "miravo-host", [
|
|
53
|
+
{ action: "create", resourceType: "device", name: "dev-1" },
|
|
54
|
+
{ action: "create", resourceType: "device", name: "dev-2" },
|
|
55
|
+
]),
|
|
56
|
+
createPlan("le-host", "miravo-host", [
|
|
57
|
+
{ action: "create", resourceType: "device", name: "dev-2" },
|
|
58
|
+
]),
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
const CLEANUP_PLAN_ATTEMPTS = [
|
|
62
|
+
createPlan("le-host", undefined, [
|
|
63
|
+
{ action: "delete", resourceType: "device", name: "dev-1" },
|
|
64
|
+
{ action: "delete", resourceType: "device", name: "dev-2" },
|
|
65
|
+
]),
|
|
66
|
+
createPlan("le-host", undefined, [{ action: "delete", resourceType: "device", name: "dev-2" }]),
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
mock.restore();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("admin-platform-stream retry accounting", () => {
|
|
74
|
+
test("apply complete persists cumulative counts across retries", async () => {
|
|
75
|
+
let applyBuildCallCount = 0;
|
|
76
|
+
let applyExecuteCallCount = 0;
|
|
77
|
+
const appendedOperations: Array<PlatformOperationRecord & TrackerSummary> = [];
|
|
78
|
+
|
|
79
|
+
using resolveApplyContextSpy = spyOn(
|
|
80
|
+
adminPlatformRoutes,
|
|
81
|
+
"resolveApplyContext",
|
|
82
|
+
).mockResolvedValue({
|
|
83
|
+
req: {
|
|
84
|
+
environmentId: "env-1",
|
|
85
|
+
template: "smart-factory",
|
|
86
|
+
ifUnmanaged: "fail",
|
|
87
|
+
scope: [],
|
|
88
|
+
},
|
|
89
|
+
env: {
|
|
90
|
+
id: "env-1",
|
|
91
|
+
platformId: leEnvironments.DEFAULT_PLATFORM_ID,
|
|
92
|
+
label: "Detroit",
|
|
93
|
+
hostname: "le-host",
|
|
94
|
+
apiToken: "token",
|
|
95
|
+
sourceHostname: "miravo-host",
|
|
96
|
+
},
|
|
97
|
+
templateId: "smart-factory",
|
|
98
|
+
hub: {} as unknown as LEDeviceHub,
|
|
99
|
+
dt: {} as unknown as LEDigitalTwins,
|
|
100
|
+
desired: { devices: [], models: [], instances: [] },
|
|
101
|
+
} satisfies ApplyContext);
|
|
102
|
+
using buildApplyPlanSpy = spyOn(leApply, "buildApplyPlan").mockImplementation(async () => {
|
|
103
|
+
const plan = latestAttemptPlan(APPLY_PLAN_ATTEMPTS, applyBuildCallCount);
|
|
104
|
+
applyBuildCallCount++;
|
|
105
|
+
return { plan, snapshot: {} as never };
|
|
106
|
+
});
|
|
107
|
+
using executePlanSpy = spyOn(leApply, "executePlan").mockImplementation(
|
|
108
|
+
async (
|
|
109
|
+
_hub,
|
|
110
|
+
_dt,
|
|
111
|
+
plan,
|
|
112
|
+
_desired,
|
|
113
|
+
opts: { onResourceComplete?: ((progress: leApply.ResourceProgress) => void) | undefined },
|
|
114
|
+
) => {
|
|
115
|
+
applyExecuteCallCount++;
|
|
116
|
+
if (applyExecuteCallCount === 1) {
|
|
117
|
+
opts.onResourceComplete?.({
|
|
118
|
+
phase: "device",
|
|
119
|
+
resourceType: "device",
|
|
120
|
+
name: "dev-1",
|
|
121
|
+
action: "create",
|
|
122
|
+
status: "success",
|
|
123
|
+
index: 1,
|
|
124
|
+
of: 2,
|
|
125
|
+
});
|
|
126
|
+
opts.onResourceComplete?.({
|
|
127
|
+
phase: "device",
|
|
128
|
+
resourceType: "device",
|
|
129
|
+
name: "dev-2",
|
|
130
|
+
action: "create",
|
|
131
|
+
status: "failed",
|
|
132
|
+
error: "transient failure",
|
|
133
|
+
index: 2,
|
|
134
|
+
of: 2,
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
plan,
|
|
138
|
+
applied: 1,
|
|
139
|
+
failed: 1,
|
|
140
|
+
failedEntries: [],
|
|
141
|
+
error: "transient failure",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
opts.onResourceComplete?.({
|
|
146
|
+
phase: "device",
|
|
147
|
+
resourceType: "device",
|
|
148
|
+
name: "dev-2",
|
|
149
|
+
action: "create",
|
|
150
|
+
status: "success",
|
|
151
|
+
index: 1,
|
|
152
|
+
of: 1,
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
plan,
|
|
156
|
+
applied: 1,
|
|
157
|
+
failed: 0,
|
|
158
|
+
failedEntries: [],
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
using createTrackerSpy = spyOn(
|
|
163
|
+
leOperations,
|
|
164
|
+
"createPlatformOperationTracker",
|
|
165
|
+
).mockImplementation(() => {
|
|
166
|
+
let progressCount = 0;
|
|
167
|
+
return {
|
|
168
|
+
onResourceComplete() {
|
|
169
|
+
progressCount++;
|
|
170
|
+
},
|
|
171
|
+
finalize(input) {
|
|
172
|
+
return buildOperationRecord(input, progressCount);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
using appendOperationSpy = spyOn(leOperations, "appendPlatformOperation").mockImplementation(
|
|
177
|
+
async (record) => {
|
|
178
|
+
appendedOperations.push(record as PlatformOperationRecord & TrackerSummary);
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
using sleepSpy = spyOn(Bun, "sleep").mockResolvedValue(undefined);
|
|
182
|
+
|
|
183
|
+
const pushedEvents: unknown[] = [];
|
|
184
|
+
const eventLog = {
|
|
185
|
+
pushExternal(event: unknown) {
|
|
186
|
+
pushedEvents.push(event);
|
|
187
|
+
},
|
|
188
|
+
} as unknown as EventLog;
|
|
189
|
+
|
|
190
|
+
// Regression: retry completion must retain successes + failures from earlier attempts.
|
|
191
|
+
const response = handleApplyStream({}, eventLog);
|
|
192
|
+
const events = await readSSE(response);
|
|
193
|
+
const complete = events.find((event) => event.event === "complete");
|
|
194
|
+
|
|
195
|
+
expect(resolveApplyContextSpy).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(buildApplyPlanSpy).toHaveBeenCalledTimes(2);
|
|
197
|
+
expect(executePlanSpy).toHaveBeenCalledTimes(2);
|
|
198
|
+
expect(createTrackerSpy).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(appendOperationSpy).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(sleepSpy).toHaveBeenCalledTimes(1);
|
|
201
|
+
expect(complete?.data).toEqual(
|
|
202
|
+
expect.objectContaining({
|
|
203
|
+
applied: 2,
|
|
204
|
+
failed: 1,
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
expect(pushedEvents).toHaveLength(4);
|
|
208
|
+
expect(pushedEvents.at(-1)).toEqual(
|
|
209
|
+
expect.objectContaining({
|
|
210
|
+
type: "platform:operation",
|
|
211
|
+
operationType: "apply",
|
|
212
|
+
applied: 2,
|
|
213
|
+
failed: 1,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
expect(appendedOperations).toEqual([
|
|
217
|
+
expect.objectContaining({
|
|
218
|
+
successCount: 2,
|
|
219
|
+
failedCount: 1,
|
|
220
|
+
blockedCount: 0,
|
|
221
|
+
progressCount: 3,
|
|
222
|
+
}),
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("cleanup complete persists cumulative counts across retries", async () => {
|
|
227
|
+
let cleanupBuildCallCount = 0;
|
|
228
|
+
let cleanupExecuteCallCount = 0;
|
|
229
|
+
const appendedOperations: Array<PlatformOperationRecord & TrackerSummary> = [];
|
|
230
|
+
|
|
231
|
+
using getEnvironmentSpy = spyOn(leEnvironments, "getEnvironment").mockResolvedValue({
|
|
232
|
+
id: "env-1",
|
|
233
|
+
platformId: leEnvironments.DEFAULT_PLATFORM_ID,
|
|
234
|
+
label: "Detroit",
|
|
235
|
+
hostname: "le-host",
|
|
236
|
+
apiToken: "token",
|
|
237
|
+
sourceHostname: "miravo-host",
|
|
238
|
+
});
|
|
239
|
+
using touchEnvironmentSpy = spyOn(leEnvironments, "touchEnvironment").mockResolvedValue();
|
|
240
|
+
using checkAuthSpy = spyOn(LEClient.prototype, "checkAuth").mockResolvedValue();
|
|
241
|
+
using buildCleanupPlanSpy = spyOn(leCleanup, "buildCleanupPlan").mockImplementation(
|
|
242
|
+
async () => {
|
|
243
|
+
const plan = latestAttemptPlan(CLEANUP_PLAN_ATTEMPTS, cleanupBuildCallCount);
|
|
244
|
+
cleanupBuildCallCount++;
|
|
245
|
+
return { plan, snapshot: {} as never };
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
using executeCleanupSpy = spyOn(leCleanup, "executeCleanup").mockImplementation(
|
|
249
|
+
async (
|
|
250
|
+
_hub,
|
|
251
|
+
_dt,
|
|
252
|
+
planResult,
|
|
253
|
+
opts: { onResourceComplete?: ((progress: leApply.ResourceProgress) => void) | undefined },
|
|
254
|
+
) => {
|
|
255
|
+
cleanupExecuteCallCount++;
|
|
256
|
+
if (cleanupExecuteCallCount === 1) {
|
|
257
|
+
opts.onResourceComplete?.({
|
|
258
|
+
phase: "device",
|
|
259
|
+
resourceType: "device",
|
|
260
|
+
name: "dev-1",
|
|
261
|
+
action: "delete",
|
|
262
|
+
status: "success",
|
|
263
|
+
index: 1,
|
|
264
|
+
of: 2,
|
|
265
|
+
});
|
|
266
|
+
opts.onResourceComplete?.({
|
|
267
|
+
phase: "device",
|
|
268
|
+
resourceType: "device",
|
|
269
|
+
name: "dev-2",
|
|
270
|
+
action: "delete",
|
|
271
|
+
status: "failed",
|
|
272
|
+
error: "transient failure",
|
|
273
|
+
index: 2,
|
|
274
|
+
of: 2,
|
|
275
|
+
});
|
|
276
|
+
return {
|
|
277
|
+
plan: planResult.plan,
|
|
278
|
+
deleted: 1,
|
|
279
|
+
failed: 1,
|
|
280
|
+
failedEntries: [],
|
|
281
|
+
error: "transient failure",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
opts.onResourceComplete?.({
|
|
286
|
+
phase: "device",
|
|
287
|
+
resourceType: "device",
|
|
288
|
+
name: "dev-2",
|
|
289
|
+
action: "delete",
|
|
290
|
+
status: "success",
|
|
291
|
+
index: 1,
|
|
292
|
+
of: 1,
|
|
293
|
+
});
|
|
294
|
+
return {
|
|
295
|
+
plan: planResult.plan,
|
|
296
|
+
deleted: 1,
|
|
297
|
+
failed: 0,
|
|
298
|
+
failedEntries: [],
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
using createTrackerSpy = spyOn(
|
|
303
|
+
leOperations,
|
|
304
|
+
"createPlatformOperationTracker",
|
|
305
|
+
).mockImplementation(() => {
|
|
306
|
+
let progressCount = 0;
|
|
307
|
+
return {
|
|
308
|
+
onResourceComplete() {
|
|
309
|
+
progressCount++;
|
|
310
|
+
},
|
|
311
|
+
finalize(input) {
|
|
312
|
+
return buildOperationRecord(input, progressCount);
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
using appendOperationSpy = spyOn(leOperations, "appendPlatformOperation").mockImplementation(
|
|
317
|
+
async (record) => {
|
|
318
|
+
appendedOperations.push(record as PlatformOperationRecord & TrackerSummary);
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
using sleepSpy = spyOn(Bun, "sleep").mockResolvedValue(undefined);
|
|
322
|
+
|
|
323
|
+
const pushedEvents: unknown[] = [];
|
|
324
|
+
const eventLog = {
|
|
325
|
+
pushExternal(event: unknown) {
|
|
326
|
+
pushedEvents.push(event);
|
|
327
|
+
},
|
|
328
|
+
} as unknown as EventLog;
|
|
329
|
+
|
|
330
|
+
// Regression: cleanup completion must retain deletions + failures from earlier attempts.
|
|
331
|
+
const response = handleCleanupStream({ environmentId: "env-1", allManaged: true }, eventLog);
|
|
332
|
+
const events = await readSSE(response);
|
|
333
|
+
const complete = events.find((event) => event.event === "complete");
|
|
334
|
+
|
|
335
|
+
expect(getEnvironmentSpy).toHaveBeenCalledTimes(1);
|
|
336
|
+
expect(touchEnvironmentSpy).toHaveBeenCalledTimes(1);
|
|
337
|
+
expect(checkAuthSpy).toHaveBeenCalledTimes(1);
|
|
338
|
+
expect(buildCleanupPlanSpy).toHaveBeenCalledTimes(2);
|
|
339
|
+
expect(executeCleanupSpy).toHaveBeenCalledTimes(2);
|
|
340
|
+
expect(createTrackerSpy).toHaveBeenCalledTimes(1);
|
|
341
|
+
expect(appendOperationSpy).toHaveBeenCalledTimes(1);
|
|
342
|
+
expect(sleepSpy).toHaveBeenCalledTimes(1);
|
|
343
|
+
expect(complete?.data).toEqual(
|
|
344
|
+
expect.objectContaining({
|
|
345
|
+
deleted: 2,
|
|
346
|
+
failed: 1,
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
expect(pushedEvents).toHaveLength(4);
|
|
350
|
+
expect(pushedEvents.at(-1)).toEqual(
|
|
351
|
+
expect.objectContaining({
|
|
352
|
+
type: "platform:operation",
|
|
353
|
+
operationType: "cleanup",
|
|
354
|
+
applied: 2,
|
|
355
|
+
failed: 1,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
expect(appendedOperations).toEqual([
|
|
359
|
+
expect.objectContaining({
|
|
360
|
+
successCount: 2,
|
|
361
|
+
failedCount: 1,
|
|
362
|
+
blockedCount: 0,
|
|
363
|
+
progressCount: 3,
|
|
364
|
+
}),
|
|
365
|
+
]);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
function createPlan(
|
|
370
|
+
targetHost: string,
|
|
371
|
+
sourceHostname: string | undefined,
|
|
372
|
+
entries: readonly Pick<PlanEntry, "action" | "resourceType" | "name">[],
|
|
373
|
+
): ApplyPlan {
|
|
374
|
+
return {
|
|
375
|
+
sourceMode: sourceHostname ? "static" : "managed",
|
|
376
|
+
targetHost,
|
|
377
|
+
sourceHostname,
|
|
378
|
+
scope: [],
|
|
379
|
+
filters: {},
|
|
380
|
+
entries,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function latestAttemptPlan<T>(attempts: readonly T[], index: number): T {
|
|
385
|
+
const plan = attempts[index] ?? attempts.at(-1);
|
|
386
|
+
if (plan !== undefined) return plan;
|
|
387
|
+
throw new Error("Expected at least one retry plan.");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function buildOperationRecord(
|
|
391
|
+
input: Parameters<ReturnType<typeof leOperations.createPlatformOperationTracker>["finalize"]>[0],
|
|
392
|
+
progressCount: number,
|
|
393
|
+
): PlatformOperationRecord & TrackerSummary {
|
|
394
|
+
return {
|
|
395
|
+
schemaVersion: 1,
|
|
396
|
+
operationId: "op-1",
|
|
397
|
+
operationType: "apply",
|
|
398
|
+
status: input.failedCount > 0 ? "partial" : "success",
|
|
399
|
+
templateId: "smart-factory",
|
|
400
|
+
environmentId: "env-1",
|
|
401
|
+
environmentLabel: "Detroit",
|
|
402
|
+
hostname: "le-host",
|
|
403
|
+
scope: [],
|
|
404
|
+
filters: {},
|
|
405
|
+
successCount: input.successCount,
|
|
406
|
+
failedCount: input.failedCount,
|
|
407
|
+
blockedCount: input.blockedCount ?? 0,
|
|
408
|
+
startedAt: 0,
|
|
409
|
+
completedAt: 0,
|
|
410
|
+
durationMs: 0,
|
|
411
|
+
resourceCounts: {
|
|
412
|
+
device: { create: 0, update: 0, replace: 0, delete: 0, failed: 0, total: 0 },
|
|
413
|
+
model: { create: 0, update: 0, replace: 0, delete: 0, failed: 0, total: 0 },
|
|
414
|
+
instance: { create: 0, update: 0, replace: 0, delete: 0, failed: 0, total: 0 },
|
|
415
|
+
"param-sync": { create: 0, update: 0, replace: 0, delete: 0, failed: 0, total: 0 },
|
|
416
|
+
},
|
|
417
|
+
progressCount,
|
|
418
|
+
...(input.error ? { error: input.error } : {}),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function readSSE(response: Response): Promise<readonly ParsedSSEEvent[]> {
|
|
423
|
+
const text = await response.text();
|
|
424
|
+
return text
|
|
425
|
+
.trim()
|
|
426
|
+
.split("\n\n")
|
|
427
|
+
.filter((chunk) => chunk.length > 0)
|
|
428
|
+
.map((chunk) => {
|
|
429
|
+
const lines = chunk.split("\n");
|
|
430
|
+
const event = lines.find((line) => line.startsWith("event: "))?.slice(7) ?? "";
|
|
431
|
+
const data = lines.find((line) => line.startsWith("data: "))?.slice(6) ?? "{}";
|
|
432
|
+
return { event, data: JSON.parse(data) as Record<string, unknown> };
|
|
433
|
+
});
|
|
434
|
+
}
|