@flow-as-code/core 0.1.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.
Files changed (164) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +30 -0
  3. package/SPEC.md +92 -0
  4. package/dist/actions.d.ts +87 -0
  5. package/dist/actions.d.ts.map +1 -0
  6. package/dist/actions.js +204 -0
  7. package/dist/actions.js.map +1 -0
  8. package/dist/aws.d.ts +27 -0
  9. package/dist/aws.d.ts.map +1 -0
  10. package/dist/aws.js +32 -0
  11. package/dist/aws.js.map +1 -0
  12. package/dist/blocks.d.ts +287 -0
  13. package/dist/blocks.d.ts.map +1 -0
  14. package/dist/blocks.js +352 -0
  15. package/dist/blocks.js.map +1 -0
  16. package/dist/codegen.d.ts +21 -0
  17. package/dist/codegen.d.ts.map +1 -0
  18. package/dist/codegen.js +1027 -0
  19. package/dist/codegen.js.map +1 -0
  20. package/dist/export.d.ts +256 -0
  21. package/dist/export.d.ts.map +1 -0
  22. package/dist/export.js +799 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/flow.d.ts +41 -0
  25. package/dist/flow.d.ts.map +1 -0
  26. package/dist/flow.js +85 -0
  27. package/dist/flow.js.map +1 -0
  28. package/dist/flowdoc.d.ts +111 -0
  29. package/dist/flowdoc.d.ts.map +1 -0
  30. package/dist/flowdoc.js +139 -0
  31. package/dist/flowdoc.js.map +1 -0
  32. package/dist/index.d.ts +16 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +26 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/layout.d.ts +11 -0
  37. package/dist/layout.d.ts.map +1 -0
  38. package/dist/layout.js +49 -0
  39. package/dist/layout.js.map +1 -0
  40. package/dist/lint/engine.d.ts +16 -0
  41. package/dist/lint/engine.d.ts.map +1 -0
  42. package/dist/lint/engine.js +37 -0
  43. package/dist/lint/engine.js.map +1 -0
  44. package/dist/lint/graph.d.ts +34 -0
  45. package/dist/lint/graph.d.ts.map +1 -0
  46. package/dist/lint/graph.js +79 -0
  47. package/dist/lint/graph.js.map +1 -0
  48. package/dist/lint/index.d.ts +7 -0
  49. package/dist/lint/index.d.ts.map +1 -0
  50. package/dist/lint/index.js +5 -0
  51. package/dist/lint/index.js.map +1 -0
  52. package/dist/lint/reporters.d.ts +6 -0
  53. package/dist/lint/reporters.d.ts.map +1 -0
  54. package/dist/lint/reporters.js +30 -0
  55. package/dist/lint/reporters.js.map +1 -0
  56. package/dist/lint/rules/action-allowed-in-flow-type.d.ts +11 -0
  57. package/dist/lint/rules/action-allowed-in-flow-type.d.ts.map +1 -0
  58. package/dist/lint/rules/action-allowed-in-flow-type.js +32 -0
  59. package/dist/lint/rules/action-allowed-in-flow-type.js.map +1 -0
  60. package/dist/lint/rules/action-count.d.ts +16 -0
  61. package/dist/lint/rules/action-count.d.ts.map +1 -0
  62. package/dist/lint/rules/action-count.js +32 -0
  63. package/dist/lint/rules/action-count.js.map +1 -0
  64. package/dist/lint/rules/error-branches.d.ts +10 -0
  65. package/dist/lint/rules/error-branches.d.ts.map +1 -0
  66. package/dist/lint/rules/error-branches.js +39 -0
  67. package/dist/lint/rules/error-branches.js.map +1 -0
  68. package/dist/lint/rules/index.d.ts +17 -0
  69. package/dist/lint/rules/index.d.ts.map +1 -0
  70. package/dist/lint/rules/index.js +36 -0
  71. package/dist/lint/rules/index.js.map +1 -0
  72. package/dist/lint/rules/module-depth-5.d.ts +14 -0
  73. package/dist/lint/rules/module-depth-5.d.ts.map +1 -0
  74. package/dist/lint/rules/module-depth-5.js +67 -0
  75. package/dist/lint/rules/module-depth-5.js.map +1 -0
  76. package/dist/lint/rules/no-literal-arn.d.ts +21 -0
  77. package/dist/lint/rules/no-literal-arn.d.ts.map +1 -0
  78. package/dist/lint/rules/no-literal-arn.js +58 -0
  79. package/dist/lint/rules/no-literal-arn.js.map +1 -0
  80. package/dist/lint/rules/no-unresolved-token.d.ts +8 -0
  81. package/dist/lint/rules/no-unresolved-token.d.ts.map +1 -0
  82. package/dist/lint/rules/no-unresolved-token.js +71 -0
  83. package/dist/lint/rules/no-unresolved-token.js.map +1 -0
  84. package/dist/lint/rules/prompt-length-3000.d.ts +16 -0
  85. package/dist/lint/rules/prompt-length-3000.d.ts.map +1 -0
  86. package/dist/lint/rules/prompt-length-3000.js +61 -0
  87. package/dist/lint/rules/prompt-length-3000.js.map +1 -0
  88. package/dist/lint/rules/reachable-blocks.d.ts +7 -0
  89. package/dist/lint/rules/reachable-blocks.d.ts.map +1 -0
  90. package/dist/lint/rules/reachable-blocks.js +45 -0
  91. package/dist/lint/rules/reachable-blocks.js.map +1 -0
  92. package/dist/lint/rules/recording-consent-before-record.d.ts +3 -0
  93. package/dist/lint/rules/recording-consent-before-record.d.ts.map +1 -0
  94. package/dist/lint/rules/recording-consent-before-record.js +87 -0
  95. package/dist/lint/rules/recording-consent-before-record.js.map +1 -0
  96. package/dist/lint/rules/terminal-blocks.d.ts +8 -0
  97. package/dist/lint/rules/terminal-blocks.d.ts.map +1 -0
  98. package/dist/lint/rules/terminal-blocks.js +40 -0
  99. package/dist/lint/rules/terminal-blocks.js.map +1 -0
  100. package/dist/lint/rules/unique-names.d.ts +7 -0
  101. package/dist/lint/rules/unique-names.d.ts.map +1 -0
  102. package/dist/lint/rules/unique-names.js +40 -0
  103. package/dist/lint/rules/unique-names.js.map +1 -0
  104. package/dist/lint/types.d.ts +30 -0
  105. package/dist/lint/types.d.ts.map +1 -0
  106. package/dist/lint/types.js +11 -0
  107. package/dist/lint/types.js.map +1 -0
  108. package/dist/materialize.d.ts +31 -0
  109. package/dist/materialize.d.ts.map +1 -0
  110. package/dist/materialize.js +192 -0
  111. package/dist/materialize.js.map +1 -0
  112. package/dist/package-names.d.ts +11 -0
  113. package/dist/package-names.d.ts.map +1 -0
  114. package/dist/package-names.js +25 -0
  115. package/dist/package-names.js.map +1 -0
  116. package/dist/refs.d.ts +35 -0
  117. package/dist/refs.d.ts.map +1 -0
  118. package/dist/refs.js +69 -0
  119. package/dist/refs.js.map +1 -0
  120. package/dist/serialize.d.ts +13 -0
  121. package/dist/serialize.d.ts.map +1 -0
  122. package/dist/serialize.js +86 -0
  123. package/dist/serialize.js.map +1 -0
  124. package/dist/simulate.d.ts +446 -0
  125. package/dist/simulate.d.ts.map +1 -0
  126. package/dist/simulate.js +1115 -0
  127. package/dist/simulate.js.map +1 -0
  128. package/dist/synth.d.ts +25 -0
  129. package/dist/synth.d.ts.map +1 -0
  130. package/dist/synth.js +93 -0
  131. package/dist/synth.js.map +1 -0
  132. package/package.json +73 -0
  133. package/src/actions.ts +221 -0
  134. package/src/aws.ts +55 -0
  135. package/src/blocks.ts +619 -0
  136. package/src/codegen.ts +1144 -0
  137. package/src/export.ts +1190 -0
  138. package/src/flow.ts +108 -0
  139. package/src/flowdoc.ts +261 -0
  140. package/src/index.ts +31 -0
  141. package/src/layout.ts +54 -0
  142. package/src/lint/engine.ts +56 -0
  143. package/src/lint/graph.ts +86 -0
  144. package/src/lint/index.ts +10 -0
  145. package/src/lint/reporters.ts +44 -0
  146. package/src/lint/rules/action-allowed-in-flow-type.ts +32 -0
  147. package/src/lint/rules/action-count.ts +35 -0
  148. package/src/lint/rules/error-branches.ts +45 -0
  149. package/src/lint/rules/index.ts +53 -0
  150. package/src/lint/rules/module-depth-5.ts +70 -0
  151. package/src/lint/rules/no-literal-arn.ts +64 -0
  152. package/src/lint/rules/no-unresolved-token.ts +80 -0
  153. package/src/lint/rules/prompt-length-3000.ts +66 -0
  154. package/src/lint/rules/reachable-blocks.ts +50 -0
  155. package/src/lint/rules/recording-consent-before-record.ts +90 -0
  156. package/src/lint/rules/terminal-blocks.ts +45 -0
  157. package/src/lint/rules/unique-names.ts +44 -0
  158. package/src/lint/types.ts +43 -0
  159. package/src/materialize.ts +230 -0
  160. package/src/package-names.ts +26 -0
  161. package/src/refs.ts +93 -0
  162. package/src/serialize.ts +97 -0
  163. package/src/simulate.ts +1610 -0
  164. package/src/synth.ts +110 -0
@@ -0,0 +1,1115 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // Simulate: authored scenarios in, executed test runs and reports out.
6
+ //
7
+ // READ THIS BEFORE CHANGING THE SHAPES HERE. There is no Amazon Connect API
8
+ // called Simulate, StartFlowSimulation, or anything containing "Simulate".
9
+ // SPEC.md described one and was wrong; it is corrected. The real family is
10
+ // eleven TestCase operations in connect-2017-08-08, present in
11
+ // @aws-sdk/client-connect 3.1122.0 (verified locally: CreateTestCaseCommand,
12
+ // StartTestCaseExecutionCommand, GetTestCaseExecutionSummaryCommand,
13
+ // ListTestCaseExecutionRecordsCommand, StopTestCaseExecutionCommand,
14
+ // DeleteTestCaseCommand and the rest all resolve).
15
+ // https://docs.aws.amazon.com/connect/latest/APIReference/API_Operations.html
16
+ //
17
+ // Two consequences the old SPEC missed and this file is built around:
18
+ //
19
+ // 1. A test case is a SERVER-SIDE RESOURCE. StartTestCaseExecution "Starts
20
+ // executing a published test case" and takes only a TestCaseId, so there is
21
+ // no submit-and-run call. A scenario file must be materialized into the
22
+ // instance with CreateTestCase(Status=PUBLISHED) first, executed, then
23
+ // deleted. The runner owns that whole lifecycle, cleanup included.
24
+ // https://docs.aws.amazon.com/connect/latest/APIReference/API_StartTestCaseExecution.html
25
+ //
26
+ // 2. Inputs and expectations are NOT API fields. They live inside the opaque
27
+ // `Content` string as the Connect Testing language: a GRAPH of Observations,
28
+ // each pairing one expected Event with the Actions to take in response.
29
+ // https://docs.aws.amazon.com/connect/latest/devguide/testing-language.html
30
+ // https://docs.aws.amazon.com/connect/latest/devguide/testing-language-example.html
31
+ //
32
+ // So a Scenario here is an authoring format of ours, deliberately the ordered
33
+ // list SPEC.md describes, and `compileScenario` lowers it to the Observation
34
+ // graph. Compilation is byte-stable and covered by goldens in
35
+ // conformance/simulate/, which is what makes the whole path testable with no
36
+ // instance. Content is validated SERVER-SIDE at CreateTestCase time, so the
37
+ // compiler's output shape is the one part of this file no offline test can
38
+ // confirm. It was verified against a live sandbox instance on 2026-09-01, and
39
+ // the Testing language pages are wrong in three places the compiler and the
40
+ // validator now encode (each is marked "verified 2026-09-01" where it applies):
41
+ //
42
+ // - `Utterance` carries its text as `Properties.Value`, not `Text` or `SSML`.
43
+ // - `Assert` has no working `Exists` operator; `Equals` needs an Operand, and
44
+ // the compiler emits one for every operator.
45
+ // - `VoiceCallEntryPointParameters` rejects `FlowId` alongside
46
+ // `DestinationPhoneNumber` ("Must specify either FlowId or phone numbers").
47
+ //
48
+ // The JS SDK surfaces the server's findings as `problemDetails: [{ message }]`
49
+ // on the InvalidTestCaseException (the API reference calls the field
50
+ // `Problems`); the adapter below folds them into the error message.
51
+ import { createRateLimiter, defaultSleep, } from "./aws.js";
52
+ import { MaterializeError } from "./materialize.js";
53
+ import { SLUG_PATTERN } from "./flowdoc.js";
54
+ import { collectRefs, isToken, parseToken, TOKEN_PATTERN } from "./refs.js";
55
+ import { ordered, sortKeys } from "./serialize.js";
56
+ /** The scenario authoring format version. Bumped with a migration and fixtures. */
57
+ export const SCENARIO_VERSION = "0.1";
58
+ /**
59
+ * Documented harness limits. None of these is a Service Quota: the Connect
60
+ * service quotas page has no row for test cases, executions, concurrency, or
61
+ * duration, so there is no quota code and no increase to request. They are
62
+ * feature behaviors documented in the admin guide.
63
+ * https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html
64
+ */
65
+ export const SIMULATE_LIMITS = {
66
+ /** "You can run up to 5 concurrent tests." */
67
+ concurrentTests: 5,
68
+ /**
69
+ * "The system accepts up to 100 test executions in the queue including the
70
+ * five running tests." The 100 is the total in flight, not 100 queued behind
71
+ * 5 running, and exceeding it is rejected with ServiceQuotaExceededException
72
+ * (HTTP 402) from StartTestCaseExecution.
73
+ */
74
+ queueCapacityIncludingRunning: 100,
75
+ /**
76
+ * "Each test simulation has a maximum duration of 5 minutes." The cap is a
77
+ * hard timeout that reports the execution as FAILED, not as a neutral stop,
78
+ * which is why every compiled scenario ends with a TestControl EndTest.
79
+ */
80
+ maxDurationMs: 300_000,
81
+ };
82
+ /**
83
+ * The ActionParameters key that names the resource being overridden, per
84
+ * action type, and the ref type that resource has. CreateTestCase rejects an
85
+ * override whose ActionParameters is empty ("InvalidFlowActionParametersProblem",
86
+ * verified 2026-09-01), so the parameter is required rather than defaulted.
87
+ * QueueId and HoursOfOperationId are verified 2026-09-01 (both substitutions
88
+ * were accepted and executed). LambdaFunctionARN comes from the documented
89
+ * shape only and has not been sent to a live instance:
90
+ * https://docs.aws.amazon.com/connect/latest/devguide/testing-language-actions-override-system-behavior.html
91
+ */
92
+ export const SUBSTITUTION_PARAMETER = {
93
+ TransferContactToQueue: { key: "QueueId", refType: "queue" },
94
+ CheckHoursOfOperation: { key: "HoursOfOperationId", refType: "hours" },
95
+ InvokeLambdaFunction: { key: "LambdaFunctionARN", refType: "lambda" },
96
+ };
97
+ /** Validation failed. Every finding is reported at once, as lint and materialize do. */
98
+ export class ScenarioValidationError extends Error {
99
+ findings;
100
+ constructor(findings) {
101
+ super(`Invalid scenario: ${String(findings.length)} problem(s): ` +
102
+ findings.map((f) => `${f.path}: ${f.message}`).join("; "));
103
+ this.name = "ScenarioValidationError";
104
+ this.findings = findings;
105
+ }
106
+ }
107
+ const ASSERT_OPERATORS = new Set([
108
+ "Equals",
109
+ "TextStartsWith",
110
+ "TextEndsWith",
111
+ "TextContains",
112
+ "NumberGreaterThan",
113
+ "NumberGreaterOrEqualTo",
114
+ "NumberLessThan",
115
+ "NumberLessOrEqualTo",
116
+ ]);
117
+ const STEP_KINDS = new Set([
118
+ "expect-prompt",
119
+ "expect-lambda",
120
+ "expect-transfer",
121
+ "expect-hours-check",
122
+ "expect-lex",
123
+ "expect-queue",
124
+ "assert",
125
+ "send-dtmf",
126
+ "send-speech",
127
+ "send-text",
128
+ "disconnect",
129
+ ]);
130
+ /** DTMF is a digit, star, or pound. */
131
+ const DTMF_PATTERN = /^[0-9*#]+$/;
132
+ const JSONPATH_PATTERN = /^\$\.[A-Za-z0-9_$.[\]'-]+$/;
133
+ /**
134
+ * SourcePhoneNumber's documented pattern, `\+[1-9]\d{1,14}$`: E.164 with no
135
+ * spaces and a country code that cannot start with 0. The leading digit is
136
+ * pinned by conformance/simulate/invalid (source-phone-number-with-a-leading-zero).
137
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_VoiceCallEntryPointParameters.html
138
+ */
139
+ const E164_PATTERN = /^\+[1-9]\d{1,14}$/;
140
+ function isRecord(value) {
141
+ return value !== null && typeof value === "object" && !Array.isArray(value);
142
+ }
143
+ function tokenOfType(value, type) {
144
+ if (typeof value !== "string")
145
+ return false;
146
+ const entry = parseToken(value);
147
+ return entry !== undefined && entry.type === type;
148
+ }
149
+ /**
150
+ * Every problem in one pass. Cross-field safety rules live here rather than in
151
+ * the JSON Schema, which cannot express them readably; the schema is the
152
+ * structural half of the same contract
153
+ * (conformance/schema/scenario-0.1.schema.json).
154
+ */
155
+ export function validateScenario(value) {
156
+ const findings = [];
157
+ const bad = (path, message) => findings.push({ path, message });
158
+ if (!isRecord(value))
159
+ return [{ path: "", message: "scenario must be a JSON object" }];
160
+ if (value.scenario !== SCENARIO_VERSION) {
161
+ bad("scenario", `must be "${SCENARIO_VERSION}"`);
162
+ }
163
+ if (typeof value.name !== "string" || !SLUG_PATTERN.test(value.name)) {
164
+ bad("name", "must be a slug: lowercase words separated by single hyphens");
165
+ }
166
+ if (value.description !== undefined && typeof value.description !== "string") {
167
+ bad("description", "must be a string");
168
+ }
169
+ const entryPoint = value.entryPoint;
170
+ if (!isRecord(entryPoint)) {
171
+ bad("entryPoint", "is required");
172
+ }
173
+ else {
174
+ if (entryPoint.channel !== "voice" && entryPoint.channel !== "chat") {
175
+ bad("entryPoint.channel", 'must be "voice" or "chat"');
176
+ }
177
+ if (!tokenOfType(entryPoint.flow, "flow")) {
178
+ bad("entryPoint.flow", "must be a ${cdref:flow:...} token");
179
+ }
180
+ const phone = entryPoint.sourcePhoneNumber;
181
+ if (entryPoint.channel === "chat" && phone !== undefined) {
182
+ // One finding per field: a number that is also malformed is still just
183
+ // a number that does not belong here.
184
+ bad("entryPoint.sourcePhoneNumber", "is voice only");
185
+ }
186
+ else if (phone !== undefined && (typeof phone !== "string" || !E164_PATTERN.test(phone))) {
187
+ bad("entryPoint.sourcePhoneNumber", "must be an E.164 number such as +15550100");
188
+ }
189
+ if (entryPoint.destinationPhoneNumber !== undefined) {
190
+ // Verified 2026-09-01: CreateTestCase answers "Must specify either FlowId
191
+ // or phone numbers" when VoiceCallEntryPointParameters carries both, and
192
+ // a scenario always carries its flow.
193
+ bad("entryPoint.destinationPhoneNumber", "is not a scenario field: the entry point names its flow, and CreateTestCase rejects a FlowId combined with a DestinationPhoneNumber");
194
+ }
195
+ }
196
+ if (value.attributes !== undefined) {
197
+ if (!isRecord(value.attributes)) {
198
+ bad("attributes", "must be an object of string values");
199
+ }
200
+ else {
201
+ for (const [key, attribute] of Object.entries(value.attributes)) {
202
+ if (typeof attribute !== "string")
203
+ bad(`attributes.${key}`, "must be a string");
204
+ }
205
+ }
206
+ }
207
+ const steps = value.steps;
208
+ if (!Array.isArray(steps) || steps.length === 0) {
209
+ bad("steps", "must be a non-empty array");
210
+ }
211
+ else {
212
+ steps.forEach((step, i) => {
213
+ const at = `steps[${String(i)}]`;
214
+ if (!isRecord(step)) {
215
+ bad(at, "must be an object");
216
+ return;
217
+ }
218
+ const kind = step.kind;
219
+ if (typeof kind !== "string" || !STEP_KINDS.has(kind)) {
220
+ bad(`${at}.kind`, `must be one of ${[...STEP_KINDS].join(", ")}`);
221
+ return;
222
+ }
223
+ switch (kind) {
224
+ case "expect-prompt": {
225
+ const hasContains = typeof step.contains === "string" && step.contains !== "";
226
+ const hasSimilar = typeof step.similarTo === "string" && step.similarTo !== "";
227
+ if (hasContains === hasSimilar) {
228
+ bad(at, "needs exactly one of contains or similarTo");
229
+ }
230
+ break;
231
+ }
232
+ case "expect-lambda":
233
+ if (!tokenOfType(step.lambda, "lambda")) {
234
+ bad(`${at}.lambda`, "must be a ${cdref:lambda:...} token");
235
+ }
236
+ break;
237
+ case "expect-transfer":
238
+ if (!tokenOfType(step.queue, "queue")) {
239
+ bad(`${at}.queue`, "must be a ${cdref:queue:...} token");
240
+ }
241
+ break;
242
+ case "expect-hours-check":
243
+ if (!tokenOfType(step.hours, "hours")) {
244
+ bad(`${at}.hours`, "must be a ${cdref:hours:...} token");
245
+ }
246
+ break;
247
+ case "expect-lex":
248
+ if (!tokenOfType(step.lex, "lex")) {
249
+ bad(`${at}.lex`, "must be a ${cdref:lex:...} token");
250
+ }
251
+ break;
252
+ case "expect-queue":
253
+ if (typeof step.name !== "string" || step.name === "") {
254
+ bad(`${at}.name`, "must be the queue name the flow reports in $.Queue.Name");
255
+ }
256
+ break;
257
+ case "assert": {
258
+ if (typeof step.path !== "string" || !JSONPATH_PATTERN.test(step.path)) {
259
+ bad(`${at}.path`, "must be a single JSONPath identifier such as $.Attributes.locale");
260
+ }
261
+ if (typeof step.operator !== "string" || !ASSERT_OPERATORS.has(step.operator)) {
262
+ bad(`${at}.operator`, `must be one of ${[...ASSERT_OPERATORS].join(", ")} (the documented Exists operator is rejected by CreateTestCase)`);
263
+ }
264
+ if (typeof step.value !== "string") {
265
+ bad(`${at}.value`, "is required: every Assert operator takes an Operand");
266
+ }
267
+ break;
268
+ }
269
+ case "send-dtmf":
270
+ if (typeof step.value !== "string" || !DTMF_PATTERN.test(step.value)) {
271
+ bad(`${at}.value`, "must be digits, * or #");
272
+ }
273
+ if (isRecord(entryPoint) && entryPoint.channel === "chat") {
274
+ // Verified 2026-09-01: "DTMF input is not supported for Chat entry point".
275
+ bad(at, "send-dtmf is voice only: CreateTestCase rejects DtmfInput under a CHAT entry point");
276
+ }
277
+ break;
278
+ case "send-speech":
279
+ case "send-text":
280
+ if (typeof step.text !== "string" || step.text === "") {
281
+ bad(`${at}.text`, "must be a non-empty string");
282
+ }
283
+ if (kind === "send-speech" &&
284
+ step.languageCode !== undefined &&
285
+ typeof step.languageCode !== "string") {
286
+ bad(`${at}.languageCode`, "must be a string such as en-US");
287
+ }
288
+ break;
289
+ default:
290
+ break;
291
+ }
292
+ });
293
+ }
294
+ if (value.substitutions !== undefined) {
295
+ if (!Array.isArray(value.substitutions)) {
296
+ bad("substitutions", "must be an array");
297
+ }
298
+ else {
299
+ value.substitutions.forEach((substitution, i) => {
300
+ const at = `substitutions[${String(i)}]`;
301
+ if (!isRecord(substitution)) {
302
+ bad(at, "must be an object");
303
+ return;
304
+ }
305
+ const spec = typeof substitution.actionType === "string" &&
306
+ Object.hasOwn(SUBSTITUTION_PARAMETER, substitution.actionType)
307
+ ? SUBSTITUTION_PARAMETER[substitution.actionType]
308
+ : undefined;
309
+ if (spec === undefined) {
310
+ bad(`${at}.actionType`, "must be TransferContactToQueue, CheckHoursOfOperation, or InvokeLambdaFunction");
311
+ return;
312
+ }
313
+ const token = `\${cdref:${spec.refType}:...}`;
314
+ // An override names the resource it replaces; an empty ActionParameters
315
+ // is rejected server-side (verified 2026-09-01).
316
+ const parameters = substitution.actionParameters;
317
+ if (!isRecord(parameters) || !tokenOfType(parameters[spec.key], spec.refType)) {
318
+ bad(`${at}.actionParameters`, `must carry ${spec.key} as a ${token} token`);
319
+ }
320
+ // Every value in the map is a resource reference by definition, and the
321
+ // compiler copies the map verbatim into Content, so a literal here would
322
+ // be a literal ARN in the test case. Same rule as the schema's
323
+ // additionalProperties.
324
+ if (isRecord(parameters)) {
325
+ for (const [key, parameter] of Object.entries(parameters)) {
326
+ if (!isToken(parameter)) {
327
+ bad(`${at}.actionParameters.${key}`, "must be a ${cdref:...} token, never a literal");
328
+ }
329
+ }
330
+ }
331
+ if (!tokenOfType(substitution.substitute, spec.refType)) {
332
+ bad(`${at}.substitute`, `must be a ${token} token`);
333
+ }
334
+ });
335
+ }
336
+ }
337
+ if (value.endTest !== undefined && typeof value.endTest !== "boolean") {
338
+ bad("endTest", "must be a boolean");
339
+ }
340
+ // Safety, and it is a hard rule rather than an option: a simulated contact
341
+ // that reaches a queue can connect to a live agent. AWS documents two
342
+ // mitigations, ending the test before the transfer and substituting a test
343
+ // queue, so a scenario that transfers must do one of them.
344
+ const transfers = Array.isArray(steps) && steps.some((s) => isRecord(s) && s.kind === "expect-transfer");
345
+ const substitutesQueue = Array.isArray(value.substitutions) &&
346
+ value.substitutions.some((s) => isRecord(s) && s.actionType === "TransferContactToQueue");
347
+ if (transfers && value.endTest === false && !substitutesQueue) {
348
+ bad("endTest", "a scenario that expects a queue transfer must end the test or substitute the queue, or the simulated contact can reach a live agent");
349
+ }
350
+ return findings;
351
+ }
352
+ /** Validates and narrows. Throws a ScenarioValidationError listing every problem. */
353
+ export function parseScenario(value) {
354
+ const findings = validateScenario(value);
355
+ if (findings.length > 0)
356
+ throw new ScenarioValidationError(findings);
357
+ return value;
358
+ }
359
+ /** Fictional, and it matches the API's E.164 pattern (verified 2026-09-01). */
360
+ const VOICE_SOURCE_DEFAULT = "+15550100";
361
+ function eventFor(step, identifier) {
362
+ switch (step.kind) {
363
+ case "expect-prompt":
364
+ return {
365
+ Identifier: identifier,
366
+ Type: "MessageReceived",
367
+ Actor: "System",
368
+ Properties: step.contains === undefined
369
+ ? { Text: step.similarTo, MatchingCriteria: { Type: "Similarity" } }
370
+ : { Text: step.contains, MatchingCriteria: { Type: "Inclusion" } },
371
+ };
372
+ case "expect-lambda":
373
+ return {
374
+ Identifier: identifier,
375
+ Type: "FlowActionStarted",
376
+ Actor: "System",
377
+ Properties: {
378
+ ActionType: "InvokeLambdaFunction",
379
+ ActionParameters: { LambdaFunctionARN: step.lambda },
380
+ },
381
+ };
382
+ case "expect-transfer":
383
+ return {
384
+ Identifier: identifier,
385
+ Type: "FlowActionStarted",
386
+ Actor: "System",
387
+ Properties: {
388
+ ActionType: "TransferContactToQueue",
389
+ ActionParameters: { QueueId: step.queue },
390
+ },
391
+ };
392
+ case "expect-hours-check":
393
+ return {
394
+ Identifier: identifier,
395
+ Type: "FlowActionStarted",
396
+ Actor: "System",
397
+ Properties: {
398
+ ActionType: "CheckHoursOfOperation",
399
+ ActionParameters: { HoursOfOperationId: step.hours },
400
+ },
401
+ };
402
+ case "expect-lex":
403
+ return {
404
+ Identifier: identifier,
405
+ Type: "FlowActionStarted",
406
+ Actor: "System",
407
+ Properties: {
408
+ ActionType: "ConnectParticipantWithLexBot",
409
+ ActionParameters: { LexV2Bot: { AliasArn: step.lex } },
410
+ },
411
+ };
412
+ default:
413
+ return undefined;
414
+ }
415
+ }
416
+ function actionFor(step, identifier) {
417
+ const sendInstruction = (instruction) => ({
418
+ Identifier: identifier,
419
+ Type: "SendInstruction",
420
+ Parameters: { ActionType: "SendInstruction", Actor: "Customer", Instruction: instruction },
421
+ Transitions: {},
422
+ });
423
+ const assert = (parameters) => ({
424
+ Identifier: identifier,
425
+ Type: "Assert",
426
+ Parameters: parameters,
427
+ Transitions: {},
428
+ });
429
+ // Utterance carries its text as `Value`. The SendInstruction page documents
430
+ // `Text` and `SSML`; CreateTestCase rejects both with "Invalid test case
431
+ // content" and accepts a non-empty `Value`, with LanguageCode optional
432
+ // (verified 2026-09-01 against a sandbox instance, chat and voice).
433
+ // https://docs.aws.amazon.com/connect/latest/devguide/testing-language-actions-send-instruction.html
434
+ switch (step.kind) {
435
+ case "send-dtmf":
436
+ return sendInstruction({ Type: "DtmfInput", Properties: { Value: step.value } });
437
+ case "send-speech":
438
+ return sendInstruction({
439
+ Type: "Utterance",
440
+ Properties: { Value: step.text, LanguageCode: step.languageCode ?? "en-US" },
441
+ });
442
+ case "send-text":
443
+ return sendInstruction({ Type: "Utterance", Properties: { Value: step.text } });
444
+ case "disconnect":
445
+ // The documented Disconnect example carries no Properties member at all.
446
+ return sendInstruction({ Type: "Disconnect" });
447
+ case "expect-queue":
448
+ return assert({ Namespace: "$.Queue.Name", Operator: "Equals", Operand: step.name });
449
+ case "assert":
450
+ return assert({ Namespace: step.path, Operator: step.operator, Operand: step.value });
451
+ default:
452
+ return undefined;
453
+ }
454
+ }
455
+ function substitutionAction(substitution, identifier) {
456
+ return {
457
+ Identifier: identifier,
458
+ Type: "OverrideSystemBehavior",
459
+ Parameters: {
460
+ ActionType: "OverrideSystemBehavior",
461
+ Behavior: {
462
+ Type: "FlowAction",
463
+ Properties: {
464
+ ActionType: substitution.actionType,
465
+ ActionParameters: substitution.actionParameters,
466
+ Strategy: { Type: "SubstituteResource", SubstituteArn: substitution.substitute },
467
+ },
468
+ },
469
+ },
470
+ Transitions: {},
471
+ };
472
+ }
473
+ /**
474
+ * Scenario in, Connect Testing language out. Deterministic: identifiers are
475
+ * positional, so the same scenario compiles to the same bytes every time.
476
+ */
477
+ export function compileScenario(scenario) {
478
+ const findings = validateScenario(scenario);
479
+ if (findings.length > 0)
480
+ throw new ScenarioValidationError(findings);
481
+ const observations = [];
482
+ const open = (event) => {
483
+ const index = observations.length + 1;
484
+ const identifier = `observation-${String(index)}`;
485
+ const observation = {
486
+ Identifier: identifier,
487
+ Event: event(`${identifier}-event`),
488
+ Actions: [],
489
+ Transitions: { NextObservations: [] },
490
+ };
491
+ observations.push(observation);
492
+ return observation;
493
+ };
494
+ // The graph always starts at TestInitiated: anything the customer does before
495
+ // the flow says something is a response to the test starting.
496
+ let current = open((identifier) => ({
497
+ Identifier: identifier,
498
+ Type: "TestInitiated",
499
+ Actor: "System",
500
+ Properties: {},
501
+ }));
502
+ // Resource substitutions apply from the first observation, before the flow
503
+ // has had a chance to transfer anywhere.
504
+ for (const substitution of scenario.substitutions ?? []) {
505
+ current.Actions.push(substitutionAction(substitution, `${current.Identifier}-action-${String(current.Actions.length + 1)}`));
506
+ }
507
+ for (const step of scenario.steps) {
508
+ // An expectation is an Event, so it opens a new Observation. Everything
509
+ // else is an Action responding to the Observation opened most recently.
510
+ const probe = eventFor(step, "probe");
511
+ if (probe !== undefined) {
512
+ current = open((identifier) => eventFor(step, identifier));
513
+ continue;
514
+ }
515
+ const action = actionFor(step, `${current.Identifier}-action-${String(current.Actions.length + 1)}`);
516
+ if (action !== undefined)
517
+ current.Actions.push(action);
518
+ }
519
+ if (scenario.endTest !== false) {
520
+ // EndTest is the safety default: AWS documents that a simulated contact
521
+ // which reaches a queue transfer "might reach the agent queue and connect
522
+ // with a live agent as a contact".
523
+ // https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html
524
+ current.Actions.push({
525
+ Identifier: `${current.Identifier}-action-${String(current.Actions.length + 1)}`,
526
+ Type: "TestControl",
527
+ Parameters: { ActionType: "TestControl", Command: { Type: "EndTest" } },
528
+ Transitions: {},
529
+ });
530
+ }
531
+ // Observations chain forward; actions chain within an observation.
532
+ observations.forEach((observation, i) => {
533
+ const next = observations[i + 1];
534
+ observation.Transitions = { NextObservations: next === undefined ? [] : [next.Identifier] };
535
+ observation.Actions.forEach((action, j) => {
536
+ const nextAction = observation.Actions[j + 1];
537
+ action.Transitions = nextAction === undefined ? {} : { NextAction: nextAction.Identifier };
538
+ });
539
+ });
540
+ // A voice entry point is FlowId plus SourcePhoneNumber and nothing else.
541
+ // CreateTestCase rejects FlowId combined with DestinationPhoneNumber ("Must
542
+ // specify either FlowId or phone numbers") and accepts FlowId alone or with
543
+ // SourcePhoneNumber (verified 2026-09-01); the fictional default keeps the
544
+ // simulated caller's number deterministic. Every field is optional in the
545
+ // API reference, which is why the rule had to be found empirically.
546
+ // https://docs.aws.amazon.com/connect/latest/APIReference/API_VoiceCallEntryPointParameters.html
547
+ const entryPoint = scenario.entryPoint.channel === "chat"
548
+ ? { Type: "CHAT", ChatEntryPointParameters: { FlowId: scenario.entryPoint.flow } }
549
+ : {
550
+ Type: "VOICE_CALL",
551
+ VoiceCallEntryPointParameters: {
552
+ SourcePhoneNumber: scenario.entryPoint.sourcePhoneNumber ?? VOICE_SOURCE_DEFAULT,
553
+ FlowId: scenario.entryPoint.flow,
554
+ },
555
+ };
556
+ const compiled = {
557
+ name: scenario.name,
558
+ ...(scenario.description === undefined ? {} : { description: scenario.description }),
559
+ entryPoint,
560
+ content: { Version: "2019-10-30", Metadata: {}, Observations: observations },
561
+ };
562
+ if (scenario.attributes !== undefined && Object.keys(scenario.attributes).length > 0) {
563
+ compiled.initializationData = JSON.stringify({
564
+ Attributes: sortKeys(scenario.attributes),
565
+ SegmentAttributes: {},
566
+ });
567
+ }
568
+ return compiled;
569
+ }
570
+ /** Canonical JSON for the CreateTestCase Content string. Byte-stable. */
571
+ export function serializeTestContent(content) {
572
+ const canonical = ordered({
573
+ Version: content.Version,
574
+ Metadata: sortKeys(content.Metadata),
575
+ Observations: content.Observations.map((observation) => ordered({
576
+ Identifier: observation.Identifier,
577
+ Event: sortKeys(observation.Event),
578
+ Actions: observation.Actions.map((action) => sortKeys(action)),
579
+ Transitions: sortKeys(observation.Transitions),
580
+ }, ["Identifier", "Event", "Actions", "Transitions"])),
581
+ }, ["Version", "Metadata", "Observations"]);
582
+ return JSON.stringify(canonical, null, 2) + "\n";
583
+ }
584
+ function resolveDeep(value, resourceMap) {
585
+ if (typeof value === "string") {
586
+ return TOKEN_PATTERN.test(value) ? (resourceMap[value] ?? value) : value;
587
+ }
588
+ if (Array.isArray(value))
589
+ return value.map((v) => resolveDeep(v, resourceMap));
590
+ if (value !== null && typeof value === "object") {
591
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [
592
+ k,
593
+ resolveDeep(v, resourceMap),
594
+ ]));
595
+ }
596
+ return value;
597
+ }
598
+ /**
599
+ * Replaces every token in a compiled scenario with its resolved value. Strict,
600
+ * and it reports every missing token at once, exactly as materializeWithMap
601
+ * does; it throws the same MaterializeError so callers handle one type.
602
+ */
603
+ export function resolveScenario(compiled, resourceMap) {
604
+ const missing = collectRefs(compiled)
605
+ .map((entry) => entry.token)
606
+ .filter((token) => !Object.hasOwn(resourceMap, token));
607
+ if (missing.length > 0)
608
+ throw new MaterializeError(missing);
609
+ return resolveDeep(compiled, resourceMap);
610
+ }
611
+ function isQuotaExceeded(error) {
612
+ return (error !== null &&
613
+ typeof error === "object" &&
614
+ error.name === "ServiceQuotaExceededException");
615
+ }
616
+ function toIso(ms) {
617
+ return new Date(ms).toISOString();
618
+ }
619
+ /**
620
+ * The COMPLETION record of an execution that Connect failed before it observed
621
+ * anything: `CompletionReason.FailureReasons` carries `INITIALIZATION_FAILURE`
622
+ * and the message says why ("Failed to start execution of test case due to
623
+ * limit reached" is the one seen live). The record's schema is not published,
624
+ * so anything that does not parse into that shape is not an admission failure.
625
+ * Returns the reason text, or undefined when the execution did start.
626
+ */
627
+ function initializationFailure(records) {
628
+ for (const { record } of records) {
629
+ if (record === undefined)
630
+ continue;
631
+ let parsed;
632
+ try {
633
+ parsed = JSON.parse(record);
634
+ }
635
+ catch {
636
+ continue;
637
+ }
638
+ if (!isRecord(parsed) || parsed.Type !== "COMPLETION" || !isRecord(parsed.CompletionReason)) {
639
+ continue;
640
+ }
641
+ const reasons = parsed.CompletionReason.FailureReasons;
642
+ if (!Array.isArray(reasons) || !reasons.includes("INITIALIZATION_FAILURE"))
643
+ continue;
644
+ const message = parsed.CompletionReason.Message;
645
+ return typeof message === "string" && message !== ""
646
+ ? `INITIALIZATION_FAILURE: ${message}`
647
+ : "INITIALIZATION_FAILURE";
648
+ }
649
+ return undefined;
650
+ }
651
+ /**
652
+ * Creates, publishes, executes, polls, collects, and deletes one test case per
653
+ * scenario, within the documented limits. The whole lifecycle exists because
654
+ * StartTestCaseExecution runs a stored, published test case and there is no
655
+ * submit-and-run call.
656
+ */
657
+ export async function runScenarios(scenarios, client, options) {
658
+ const concurrency = options.concurrency ?? SIMULATE_LIMITS.concurrentTests;
659
+ if (concurrency < 1 || concurrency > SIMULATE_LIMITS.concurrentTests) {
660
+ throw new Error(`concurrency must be between 1 and ${String(SIMULATE_LIMITS.concurrentTests)}: Amazon Connect runs at most that many tests at once and queues the rest.`);
661
+ }
662
+ const maxInFlight = options.maxInFlight ?? SIMULATE_LIMITS.queueCapacityIncludingRunning;
663
+ if (maxInFlight < 1 || maxInFlight > SIMULATE_LIMITS.queueCapacityIncludingRunning) {
664
+ throw new Error(`maxInFlight must be between 1 and ${String(SIMULATE_LIMITS.queueCapacityIncludingRunning)}: that is the total queue depth, running tests included.`);
665
+ }
666
+ const timeoutMs = Math.min(options.timeoutMs ?? SIMULATE_LIMITS.maxDurationMs, SIMULATE_LIMITS.maxDurationMs);
667
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
668
+ const now = options.now ?? Date.now;
669
+ const sleep = options.sleep ?? defaultSleep;
670
+ const cleanup = options.cleanup !== false;
671
+ const startRetries = options.startRetries ?? 3;
672
+ // ClientToken makes StartTestCaseExecution idempotent, so a second start must
673
+ // not repeat the first attempt's token or it would name the execution that
674
+ // already failed instead of starting another. The first attempt keeps the
675
+ // scenario name so a retried run of the same suite stays idempotent.
676
+ // https://docs.aws.amazon.com/connect/latest/APIReference/API_StartTestCaseExecution.html
677
+ const clientTokenFor = (scenario, attempt) => options.clientToken?.(scenario, attempt) ??
678
+ (attempt === 0 ? scenario.name : `${scenario.name}-retry-${String(attempt)}`);
679
+ const startedAtMs = now();
680
+ const results = new Array(scenarios.length);
681
+ let cursor = 0;
682
+ let inFlight = 0;
683
+ const runOne = async (scenario) => {
684
+ const scenarioStart = now();
685
+ const result = { name: scenario.name, status: "ERRORED", durationMs: 0 };
686
+ let testCaseId;
687
+ let executionId;
688
+ try {
689
+ const resolved = resolveScenario(compileScenario(scenario), options.resourceMap);
690
+ const created = await client.createTestCase({
691
+ name: scenario.name,
692
+ ...(resolved.description === undefined ? {} : { description: resolved.description }),
693
+ content: serializeTestContent(resolved.content),
694
+ entryPoint: resolved.entryPoint,
695
+ ...(resolved.initializationData === undefined
696
+ ? {}
697
+ : { initializationData: resolved.initializationData }),
698
+ // PUBLISHED also triggers server-side validation of Content, which is
699
+ // where a malformed Testing language document is caught.
700
+ status: "PUBLISHED",
701
+ });
702
+ testCaseId = created.testCaseId;
703
+ result.testCaseId = testCaseId;
704
+ // One retry budget covers both ways a start can fail: synchronously with
705
+ // a 402, and asynchronously with an execution that Connect reports
706
+ // FAILED before it observed anything (INITIALIZATION_FAILURE, "Failed to
707
+ // start execution of test case due to limit reached", seen live when
708
+ // several voice simulations ran back to back). Neither is a verdict on
709
+ // the scenario, so the same test case is started again.
710
+ let attempt = 0;
711
+ for (;;) {
712
+ let started;
713
+ while (started === undefined) {
714
+ try {
715
+ started = await client.startExecution(testCaseId, clientTokenFor(scenario, attempt));
716
+ }
717
+ catch (error) {
718
+ // 402 means the 100-deep execution queue is full. Back off; it is
719
+ // the one start error that clears on its own.
720
+ if (!isQuotaExceeded(error) || attempt >= startRetries)
721
+ throw error;
722
+ attempt += 1;
723
+ await sleep(pollIntervalMs * attempt);
724
+ }
725
+ }
726
+ executionId = started.testCaseExecutionId;
727
+ result.executionId = executionId;
728
+ // Each attempt is its own execution. The counts of one that did not
729
+ // start ({0, 0, 0}) must not stand in for the next one when Connect
730
+ // reaches a terminal status without reporting a summary, or the result
731
+ // would read "0 of 0 observations failed" for an execution that ran.
732
+ delete result.observations;
733
+ let status = started.status;
734
+ let summary;
735
+ let timedOut = false;
736
+ while (status === "INITIATED" || status === "IN_PROGRESS") {
737
+ if (now() - scenarioStart >= timeoutMs) {
738
+ timedOut = true;
739
+ try {
740
+ await client.stopExecution(testCaseId, executionId);
741
+ }
742
+ catch {
743
+ // Best effort: Connect fails the execution at 5 minutes anyway.
744
+ }
745
+ break;
746
+ }
747
+ await sleep(pollIntervalMs);
748
+ summary = await client.getExecutionSummary(testCaseId, executionId);
749
+ status = summary.status;
750
+ }
751
+ if (summary?.observations !== undefined) {
752
+ result.observations = {
753
+ total: summary.observations.total ?? 0,
754
+ passed: summary.observations.passed ?? 0,
755
+ failed: summary.observations.failed ?? 0,
756
+ };
757
+ }
758
+ if (timedOut) {
759
+ result.status = "TIMED_OUT";
760
+ result.message = `No terminal status within ${String(timeoutMs)} ms. Amazon Connect also times a simulation out at 5 minutes and reports it FAILED, so the scenario probably never reached a TestControl EndTest.`;
761
+ }
762
+ else if (status === "PASSED") {
763
+ result.status = "PASSED";
764
+ }
765
+ else if (status === "STOPPED") {
766
+ result.status = "STOPPED";
767
+ result.message = "Execution was stopped.";
768
+ }
769
+ else {
770
+ const records = await client.listExecutionRecords(testCaseId, executionId);
771
+ const notStarted = initializationFailure(records);
772
+ if (notStarted !== undefined && attempt < startRetries) {
773
+ attempt += 1;
774
+ result.details = [
775
+ ...(result.details ?? []),
776
+ `Execution ${executionId} did not start (${notStarted}); started again.`,
777
+ ];
778
+ await sleep(pollIntervalMs * attempt);
779
+ continue;
780
+ }
781
+ result.status = "FAILED";
782
+ const failed = records.filter((record) => record.status === "FAILED");
783
+ result.details = [
784
+ ...(result.details ?? []),
785
+ ...failed
786
+ .map((record) => record.record ?? record.observationId ?? "")
787
+ .filter((detail) => detail !== ""),
788
+ ];
789
+ result.message =
790
+ notStarted !== undefined
791
+ ? `Amazon Connect did not start the execution (${notStarted}); ${String(attempt + 1)} attempt(s). The scenario was not evaluated.`
792
+ : result.observations === undefined
793
+ ? "Execution failed."
794
+ : `${String(result.observations.failed)} of ${String(result.observations.total)} observations failed.`;
795
+ }
796
+ break;
797
+ }
798
+ }
799
+ catch (error) {
800
+ result.status = "ERRORED";
801
+ result.message = error instanceof Error ? error.message : String(error);
802
+ }
803
+ finally {
804
+ if (cleanup && testCaseId !== undefined) {
805
+ try {
806
+ await client.deleteTestCase(testCaseId);
807
+ }
808
+ catch (error) {
809
+ // A leaked test case is a nuisance, not a failed scenario, but it is
810
+ // instance state so it must be visible.
811
+ result.details = [
812
+ ...(result.details ?? []),
813
+ `Cleanup failed for test case ${testCaseId}: ${error instanceof Error ? error.message : String(error)}`,
814
+ ];
815
+ }
816
+ }
817
+ result.durationMs = now() - scenarioStart;
818
+ }
819
+ return result;
820
+ };
821
+ const worker = async () => {
822
+ for (;;) {
823
+ const index = cursor;
824
+ cursor += 1;
825
+ if (index >= scenarios.length)
826
+ return;
827
+ const scenario = scenarios[index];
828
+ if (scenario === undefined)
829
+ return;
830
+ // Admission control. The runner never has more executions in flight than
831
+ // the instance-wide queue accepts, even when a caller shares the account
832
+ // with other runners and lowers the ceiling.
833
+ while (inFlight >= maxInFlight)
834
+ await sleep(pollIntervalMs);
835
+ inFlight += 1;
836
+ try {
837
+ results[index] = await runOne(scenario);
838
+ }
839
+ finally {
840
+ inFlight -= 1;
841
+ }
842
+ }
843
+ };
844
+ await Promise.all(Array.from({ length: Math.min(concurrency, scenarios.length) }, () => worker()));
845
+ const finishedAtMs = now();
846
+ const finished = [...results].filter((result) => result !== undefined);
847
+ const count = (status) => finished.filter((r) => r.status === status).length;
848
+ return {
849
+ startedAt: toIso(startedAtMs),
850
+ finishedAt: toIso(finishedAtMs),
851
+ totals: {
852
+ total: finished.length,
853
+ passed: count("PASSED"),
854
+ failed: count("FAILED"),
855
+ timedOut: count("TIMED_OUT"),
856
+ errored: count("ERRORED"),
857
+ stopped: count("STOPPED"),
858
+ },
859
+ results: finished,
860
+ };
861
+ }
862
+ // --- Reporters ---------------------------------------------------------------
863
+ /** Deterministic JSON report. Fixed key order, two-space indent, trailing newline. */
864
+ export function jsonReport(run) {
865
+ const report = ordered({
866
+ schema: "flow-simulate-report/0.1",
867
+ startedAt: run.startedAt,
868
+ finishedAt: run.finishedAt,
869
+ totals: ordered({ ...run.totals }, [
870
+ "total",
871
+ "passed",
872
+ "failed",
873
+ "timedOut",
874
+ "errored",
875
+ "stopped",
876
+ ]),
877
+ results: run.results.map((result) => ordered({ ...result }, [
878
+ "name",
879
+ "status",
880
+ "durationMs",
881
+ "testCaseId",
882
+ "executionId",
883
+ "observations",
884
+ "message",
885
+ "details",
886
+ ])),
887
+ }, ["schema", "startedAt", "finishedAt", "totals", "results"]);
888
+ return JSON.stringify(report, null, 2) + "\n";
889
+ }
890
+ /**
891
+ * XML 1.0 permits only tab, LF, CR and >= U+0020 as characters. `details` is
892
+ * filled verbatim from ExecutionRecord.Record, an opaque server-supplied
893
+ * string, so any other C0 control byte would produce a document no parser will
894
+ * read. Those are replaced with U+FFFD rather than escaped, because numeric
895
+ * character references to forbidden code points are themselves invalid XML.
896
+ *
897
+ * Tab, LF and CR are legal as content but are normalized away inside attribute
898
+ * values, so they are emitted as character references to survive a round trip.
899
+ */
900
+ // eslint-disable-next-line no-control-regex
901
+ const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g;
902
+ function escapeXml(value) {
903
+ return value
904
+ .replace(XML_FORBIDDEN, "\uFFFD")
905
+ .replace(/&/g, "&amp;")
906
+ .replace(/</g, "&lt;")
907
+ .replace(/>/g, "&gt;")
908
+ .replace(/"/g, "&quot;")
909
+ .replace(/'/g, "&apos;")
910
+ .replace(/\t/g, "&#9;")
911
+ .replace(/\n/g, "&#10;")
912
+ .replace(/\r/g, "&#13;");
913
+ }
914
+ function seconds(ms) {
915
+ return (ms / 1000).toFixed(3);
916
+ }
917
+ /**
918
+ * JUnit XML, deterministic for the same run: fixed attribute order, three
919
+ * decimal places, results in the order the suite declared them.
920
+ */
921
+ export function junitReport(run, options = {}) {
922
+ const suiteName = options.suiteName ?? "flow-simulate";
923
+ const failures = run.totals.failed + run.totals.timedOut;
924
+ const time = seconds(run.results.reduce((sum, result) => sum + result.durationMs, 0));
925
+ const attributes = `name="${escapeXml(suiteName)}" tests="${String(run.totals.total)}" ` +
926
+ `failures="${String(failures)}" errors="${String(run.totals.errored)}" ` +
927
+ `skipped="${String(run.totals.stopped)}" time="${time}"`;
928
+ const lines = [
929
+ '<?xml version="1.0" encoding="UTF-8"?>',
930
+ `<testsuites ${attributes}>`,
931
+ ` <testsuite ${attributes} timestamp="${run.startedAt}">`,
932
+ ];
933
+ for (const result of run.results) {
934
+ const head = ` <testcase name="${escapeXml(result.name)}" classname="${escapeXml(suiteName)}" time="${seconds(result.durationMs)}"`;
935
+ if (result.status === "PASSED") {
936
+ lines.push(`${head} />`);
937
+ continue;
938
+ }
939
+ const body = [result.message ?? "", ...(result.details ?? [])]
940
+ .filter((s) => s !== "")
941
+ .join("\n");
942
+ const message = escapeXml(result.message ?? result.status);
943
+ lines.push(`${head}>`);
944
+ if (result.status === "ERRORED") {
945
+ lines.push(` <error message="${message}" type="${result.status}">${escapeXml(body)}</error>`);
946
+ }
947
+ else if (result.status === "STOPPED") {
948
+ lines.push(` <skipped message="${message}" />`);
949
+ }
950
+ else {
951
+ lines.push(` <failure message="${message}" type="${result.status}">${escapeXml(body)}</failure>`);
952
+ }
953
+ lines.push(" </testcase>");
954
+ }
955
+ lines.push(" </testsuite>", "</testsuites>");
956
+ return lines.join("\n") + "\n";
957
+ }
958
+ // --- AWS SDK adapter ---------------------------------------------------------
959
+ // Verified 2026-09-01 against a sandbox instance in us-west-2 with
960
+ // @aws-sdk/client-connect 3.1122.0: the env-gated integration test ran the
961
+ // three-scenario suite through create, start, poll, list records, and delete.
962
+ // StopTestCaseExecution was not reached live (no scenario hit the harness
963
+ // timeout); the offline timeout test drives it through a fake client only.
964
+ // There is no local evaluator for the Testing language, so the integration
965
+ // test is still the only thing that exercises this adapter against Connect.
966
+ // See tasks/A06-export-and-simulate.md.
967
+ /**
968
+ * Folds the server's per-problem findings into the error message. The JS SDK
969
+ * exposes them as `problemDetails: [{ message }]` on InvalidTestCaseException;
970
+ * the API reference names the field `Problems`. Without this the runner reports
971
+ * the bare "Invalid test case content " and nothing to act on.
972
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_CreateTestCase.html
973
+ */
974
+ export function describeTestCaseError(error) {
975
+ if (!(error instanceof Error))
976
+ return new Error(String(error));
977
+ const details = error.problemDetails;
978
+ if (!Array.isArray(details) || details.length === 0)
979
+ return error;
980
+ const messages = details
981
+ .map((d) => (isRecord(d) && typeof d.message === "string" ? d.message : ""))
982
+ .filter((m) => m !== "");
983
+ if (messages.length === 0)
984
+ return error;
985
+ const described = new Error(`${error.message.trim()}: ${messages.join("; ")}`, { cause: error });
986
+ described.name = error.name;
987
+ return described;
988
+ }
989
+ const REQUIRED_COMMANDS = [
990
+ "CreateTestCaseCommand",
991
+ "StartTestCaseExecutionCommand",
992
+ "GetTestCaseExecutionSummaryCommand",
993
+ "ListTestCaseExecutionRecordsCommand",
994
+ "StopTestCaseExecutionCommand",
995
+ "DeleteTestCaseCommand",
996
+ ];
997
+ async function loadTestCaseCommands() {
998
+ let module;
999
+ try {
1000
+ module = (await import("@aws-sdk/client-connect"));
1001
+ }
1002
+ catch (cause) {
1003
+ throw new Error("Running scenarios against an instance needs the optional peer dependency @aws-sdk/client-connect. Install it, or use the offline paths (compileScenario, serializeTestContent, the reporters).", { cause });
1004
+ }
1005
+ const missing = REQUIRED_COMMANDS.filter((name) => typeof module[name] !== "function");
1006
+ if (missing.length > 0) {
1007
+ throw new Error(`The installed @aws-sdk/client-connect does not expose ${missing.join(", ")}. The Amazon Connect TestCase operations are recent; upgrade the SDK (3.1122.0 has them all).`);
1008
+ }
1009
+ return module;
1010
+ }
1011
+ /**
1012
+ * FlowTestClient over the AWS SDK. Rate limiting and the response-field naming
1013
+ * differences live here; the runner sees neither.
1014
+ */
1015
+ export function createConnectTestClient(options) {
1016
+ const { connect, instanceId } = options;
1017
+ let commands;
1018
+ const throttle = createRateLimiter(options);
1019
+ const send = async (make) => {
1020
+ commands ??= await loadTestCaseCommands();
1021
+ await throttle();
1022
+ return connect.send(make(commands));
1023
+ };
1024
+ const iso = (value) => value instanceof Date ? value.toISOString() : typeof value === "string" ? value : undefined;
1025
+ return {
1026
+ createTestCase: async (input) => {
1027
+ let response;
1028
+ try {
1029
+ response = await send((c) => new c.CreateTestCaseCommand({
1030
+ InstanceId: instanceId,
1031
+ Name: input.name,
1032
+ Description: input.description,
1033
+ Content: input.content,
1034
+ EntryPoint: input.entryPoint,
1035
+ InitializationData: input.initializationData,
1036
+ Status: input.status,
1037
+ }));
1038
+ }
1039
+ catch (error) {
1040
+ throw describeTestCaseError(error);
1041
+ }
1042
+ return { testCaseId: response.TestCaseId ?? "", testCaseArn: response.TestCaseArn };
1043
+ },
1044
+ startExecution: async (testCaseId, clientToken) => {
1045
+ const response = await send((c) => new c.StartTestCaseExecutionCommand({
1046
+ InstanceId: instanceId,
1047
+ TestCaseId: testCaseId,
1048
+ ClientToken: clientToken,
1049
+ }));
1050
+ return {
1051
+ testCaseExecutionId: response.TestCaseExecutionId ?? "",
1052
+ status: (response.Status ?? "INITIATED"),
1053
+ };
1054
+ },
1055
+ getExecutionSummary: async (testCaseId, executionId) => {
1056
+ const response = await send((c) => new c.GetTestCaseExecutionSummaryCommand({
1057
+ InstanceId: instanceId,
1058
+ TestCaseId: testCaseId,
1059
+ TestCaseExecutionId: executionId,
1060
+ }));
1061
+ const summary = {
1062
+ status: (response.Status ?? "IN_PROGRESS"),
1063
+ };
1064
+ const startTime = iso(response.StartTime);
1065
+ const endTime = iso(response.EndTime);
1066
+ if (startTime !== undefined)
1067
+ summary.startTime = startTime;
1068
+ if (endTime !== undefined)
1069
+ summary.endTime = endTime;
1070
+ if (response.ObservationSummary !== undefined) {
1071
+ summary.observations = {
1072
+ total: response.ObservationSummary.TotalObservations,
1073
+ passed: response.ObservationSummary.ObservationsPassed,
1074
+ failed: response.ObservationSummary.ObservationsFailed,
1075
+ };
1076
+ }
1077
+ return summary;
1078
+ },
1079
+ listExecutionRecords: async (testCaseId, executionId) => {
1080
+ const out = [];
1081
+ let nextToken;
1082
+ do {
1083
+ const response = await send((c) => new c.ListTestCaseExecutionRecordsCommand({
1084
+ InstanceId: instanceId,
1085
+ TestCaseId: testCaseId,
1086
+ TestCaseExecutionId: executionId,
1087
+ MaxResults: 100,
1088
+ NextToken: nextToken,
1089
+ }));
1090
+ for (const record of response.ExecutionRecords ?? []) {
1091
+ out.push({
1092
+ observationId: record.ObservationId,
1093
+ status: record.Status,
1094
+ timestamp: iso(record.Timestamp),
1095
+ record: record.Record,
1096
+ });
1097
+ }
1098
+ nextToken =
1099
+ response.NextToken === "" ? undefined : response.NextToken;
1100
+ } while (nextToken !== undefined);
1101
+ return out;
1102
+ },
1103
+ stopExecution: async (testCaseId, executionId) => {
1104
+ await send((c) => new c.StopTestCaseExecutionCommand({
1105
+ InstanceId: instanceId,
1106
+ TestCaseId: testCaseId,
1107
+ TestCaseExecutionId: executionId,
1108
+ }));
1109
+ },
1110
+ deleteTestCase: async (testCaseId) => {
1111
+ await send((c) => new c.DeleteTestCaseCommand({ InstanceId: instanceId, TestCaseId: testCaseId }));
1112
+ },
1113
+ };
1114
+ }
1115
+ //# sourceMappingURL=simulate.js.map