@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,1610 @@
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
+
52
+ import {
53
+ createRateLimiter,
54
+ defaultSleep,
55
+ type AwsCommandSender,
56
+ type RateLimiterOptions,
57
+ } from "./aws.js";
58
+ import { MaterializeError } from "./materialize.js";
59
+ import { SLUG_PATTERN } from "./flowdoc.js";
60
+ import { collectRefs, isToken, parseToken, TOKEN_PATTERN } from "./refs.js";
61
+ import { ordered, sortKeys } from "./serialize.js";
62
+
63
+ /** The scenario authoring format version. Bumped with a migration and fixtures. */
64
+ export const SCENARIO_VERSION = "0.1";
65
+
66
+ /**
67
+ * Documented harness limits. None of these is a Service Quota: the Connect
68
+ * service quotas page has no row for test cases, executions, concurrency, or
69
+ * duration, so there is no quota code and no increase to request. They are
70
+ * feature behaviors documented in the admin guide.
71
+ * https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html
72
+ */
73
+ export const SIMULATE_LIMITS = {
74
+ /** "You can run up to 5 concurrent tests." */
75
+ concurrentTests: 5,
76
+ /**
77
+ * "The system accepts up to 100 test executions in the queue including the
78
+ * five running tests." The 100 is the total in flight, not 100 queued behind
79
+ * 5 running, and exceeding it is rejected with ServiceQuotaExceededException
80
+ * (HTTP 402) from StartTestCaseExecution.
81
+ */
82
+ queueCapacityIncludingRunning: 100,
83
+ /**
84
+ * "Each test simulation has a maximum duration of 5 minutes." The cap is a
85
+ * hard timeout that reports the execution as FAILED, not as a neutral stop,
86
+ * which is why every compiled scenario ends with a TestControl EndTest.
87
+ */
88
+ maxDurationMs: 300_000,
89
+ } as const;
90
+
91
+ // --- Scenario authoring format ----------------------------------------------
92
+
93
+ /**
94
+ * The channel is an ENTRY POINT property, not an input kind: the API enum is
95
+ * TestCaseEntryPointType = CHAT | VOICE_CALL. DTMF, speech, and typed text are
96
+ * all SendInstruction instructions within whichever channel the entry point
97
+ * selected. SPEC.md conflated the two axes and is corrected.
98
+ */
99
+ export type ScenarioChannel = "voice" | "chat";
100
+
101
+ export interface ScenarioEntryPoint {
102
+ channel: ScenarioChannel;
103
+ /**
104
+ * `${cdref:flow:...}`; resolved to the entry point's FlowId before
105
+ * CreateTestCase. The resource map may hold either the bare flow id or the
106
+ * contact-flow ARN: the API reference types FlowId as a string of up to 500
107
+ * characters, and CreateTestCase accepted both forms for VOICE_CALL and CHAT
108
+ * (verified 2026-09-01).
109
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_VoiceCallEntryPointParameters.html
110
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_ChatEntryPointParameters.html
111
+ */
112
+ flow: string;
113
+ /**
114
+ * Voice only. The simulated caller's number, E.164 (`\+[1-9]\d{1,14}`).
115
+ * Defaults to the fictional VOICE_SOURCE_DEFAULT. There is no destination
116
+ * number: the API's VoiceCallEntryPointParameters treats FlowId and
117
+ * DestinationPhoneNumber as alternatives ("Must specify either FlowId or
118
+ * phone numbers", verified 2026-09-01), and a scenario always names its flow.
119
+ */
120
+ sourcePhoneNumber?: string;
121
+ }
122
+
123
+ /**
124
+ * Assert operators, from the assertion action page minus `Exists`. The page
125
+ * lists `Exists` with "operand not required", and CreateTestCase rejects it
126
+ * with "Invalid operator parameter for AssertAction" whether or not an Operand
127
+ * is given, as it rejects every other spelling tried (verified 2026-09-01).
128
+ * `Equals` without an Operand is rejected and `TextContains` and
129
+ * `TextStartsWith` accept an empty one; the other operators were only sent
130
+ * with an Operand, so the compiler always emits one.
131
+ * https://docs.aws.amazon.com/connect/latest/devguide/testing-language-actions-assertion.html
132
+ */
133
+ export type AssertOperator =
134
+ | "Equals"
135
+ | "TextStartsWith"
136
+ | "TextEndsWith"
137
+ | "TextContains"
138
+ | "NumberGreaterThan"
139
+ | "NumberGreaterOrEqualTo"
140
+ | "NumberLessThan"
141
+ | "NumberLessOrEqualTo";
142
+
143
+ /**
144
+ * One ordered step. Every `expect-*` step opens a new Observation; every other
145
+ * step becomes an Action on the Observation opened most recently.
146
+ */
147
+ export type ScenarioStep =
148
+ /** MessageReceived with MatchingCriteria Inclusion (contains) or Similarity. */
149
+ | { kind: "expect-prompt"; contains?: string; similarTo?: string }
150
+ /** FlowActionStarted / InvokeLambdaFunction. SPEC called this an expectation; it is an event. */
151
+ | { kind: "expect-lambda"; lambda: string }
152
+ /** FlowActionStarted / TransferContactToQueue. */
153
+ | { kind: "expect-transfer"; queue: string }
154
+ /** FlowActionStarted / CheckHoursOfOperation. */
155
+ | { kind: "expect-hours-check"; hours: string }
156
+ /** FlowActionStarted / ConnectParticipantWithLexBot. */
157
+ | { kind: "expect-lex"; lex: string }
158
+ /** Assert on $.Queue.Name, the documented namespace example. */
159
+ | { kind: "expect-queue"; name: string }
160
+ /** Assert on any namespace. "attribute set" from SPEC.md lands here. */
161
+ | { kind: "assert"; path: string; operator: AssertOperator; value: string }
162
+ /** Voice only: CreateTestCase rejects DtmfInput under a CHAT entry point. */
163
+ | { kind: "send-dtmf"; value: string }
164
+ | { kind: "send-speech"; text: string; languageCode?: string }
165
+ | { kind: "send-text"; text: string }
166
+ | { kind: "disconnect" };
167
+
168
+ export type SubstitutionActionType =
169
+ "TransferContactToQueue" | "CheckHoursOfOperation" | "InvokeLambdaFunction";
170
+
171
+ /**
172
+ * The ActionParameters key that names the resource being overridden, per
173
+ * action type, and the ref type that resource has. CreateTestCase rejects an
174
+ * override whose ActionParameters is empty ("InvalidFlowActionParametersProblem",
175
+ * verified 2026-09-01), so the parameter is required rather than defaulted.
176
+ * QueueId and HoursOfOperationId are verified 2026-09-01 (both substitutions
177
+ * were accepted and executed). LambdaFunctionARN comes from the documented
178
+ * shape only and has not been sent to a live instance:
179
+ * https://docs.aws.amazon.com/connect/latest/devguide/testing-language-actions-override-system-behavior.html
180
+ */
181
+ export const SUBSTITUTION_PARAMETER: Readonly<
182
+ Record<SubstitutionActionType, { key: string; refType: "queue" | "hours" | "lambda" }>
183
+ > = {
184
+ TransferContactToQueue: { key: "QueueId", refType: "queue" },
185
+ CheckHoursOfOperation: { key: "HoursOfOperationId", refType: "hours" },
186
+ InvokeLambdaFunction: { key: "LambdaFunctionARN", refType: "lambda" },
187
+ };
188
+
189
+ /**
190
+ * Swaps a production resource for a test one at run time. AWS documents this
191
+ * as one of the two ways to keep a simulated contact away from a live agent.
192
+ * https://docs.aws.amazon.com/connect/latest/devguide/testing-language-actions-override-system-behavior.html
193
+ */
194
+ export interface ScenarioSubstitution {
195
+ actionType: SubstitutionActionType;
196
+ /**
197
+ * Names the resource the flow uses, as the `${cdref:...}` token under the key
198
+ * SUBSTITUTION_PARAMETER gives for the action type (QueueId,
199
+ * HoursOfOperationId, or LambdaFunctionARN). Every value in the map is a
200
+ * token: the compiler copies the map verbatim into Content, so anything else
201
+ * would put a literal into the test case. A token may resolve to a bare id or
202
+ * an ARN; both are accepted (verified 2026-09-01).
203
+ */
204
+ actionParameters: Record<string, string>;
205
+ /** `${cdref:...}` of the same ref type, for the resource to substitute in. */
206
+ substitute: string;
207
+ }
208
+
209
+ export interface Scenario {
210
+ scenario: typeof SCENARIO_VERSION;
211
+ /** Slug. Used as the Connect test case Name and as the JUnit testcase name. */
212
+ name: string;
213
+ description?: string;
214
+ entryPoint: ScenarioEntryPoint;
215
+ /** Initial contact attributes. Becomes the InitializationData JSON string. */
216
+ attributes?: Record<string, string>;
217
+ steps: ScenarioStep[];
218
+ substitutions?: ScenarioSubstitution[];
219
+ /**
220
+ * Append a TestControl EndTest action. Default true, and turning it off is a
221
+ * validation error for any scenario that expects a queue transfer: without an
222
+ * explicit end "the simulated contact might reach the agent queue and connect
223
+ * with a live agent as a contact", and the run costs a full 5 minutes and
224
+ * reports FAILED besides.
225
+ * https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html
226
+ */
227
+ endTest?: boolean;
228
+ }
229
+
230
+ // --- Validation --------------------------------------------------------------
231
+
232
+ export interface ScenarioFinding {
233
+ /** JSON-ish path into the scenario, e.g. `steps[2].value`. */
234
+ path: string;
235
+ message: string;
236
+ }
237
+
238
+ /** Validation failed. Every finding is reported at once, as lint and materialize do. */
239
+ export class ScenarioValidationError extends Error {
240
+ readonly findings: readonly ScenarioFinding[];
241
+
242
+ constructor(findings: readonly ScenarioFinding[]) {
243
+ super(
244
+ `Invalid scenario: ${String(findings.length)} problem(s): ` +
245
+ findings.map((f) => `${f.path}: ${f.message}`).join("; "),
246
+ );
247
+ this.name = "ScenarioValidationError";
248
+ this.findings = findings;
249
+ }
250
+ }
251
+
252
+ const ASSERT_OPERATORS: ReadonlySet<string> = new Set<AssertOperator>([
253
+ "Equals",
254
+ "TextStartsWith",
255
+ "TextEndsWith",
256
+ "TextContains",
257
+ "NumberGreaterThan",
258
+ "NumberGreaterOrEqualTo",
259
+ "NumberLessThan",
260
+ "NumberLessOrEqualTo",
261
+ ]);
262
+
263
+ const STEP_KINDS: ReadonlySet<string> = new Set([
264
+ "expect-prompt",
265
+ "expect-lambda",
266
+ "expect-transfer",
267
+ "expect-hours-check",
268
+ "expect-lex",
269
+ "expect-queue",
270
+ "assert",
271
+ "send-dtmf",
272
+ "send-speech",
273
+ "send-text",
274
+ "disconnect",
275
+ ]);
276
+
277
+ /** DTMF is a digit, star, or pound. */
278
+ const DTMF_PATTERN = /^[0-9*#]+$/;
279
+ const JSONPATH_PATTERN = /^\$\.[A-Za-z0-9_$.[\]'-]+$/;
280
+ /**
281
+ * SourcePhoneNumber's documented pattern, `\+[1-9]\d{1,14}$`: E.164 with no
282
+ * spaces and a country code that cannot start with 0. The leading digit is
283
+ * pinned by conformance/simulate/invalid (source-phone-number-with-a-leading-zero).
284
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_VoiceCallEntryPointParameters.html
285
+ */
286
+ const E164_PATTERN = /^\+[1-9]\d{1,14}$/;
287
+
288
+ function isRecord(value: unknown): value is Record<string, unknown> {
289
+ return value !== null && typeof value === "object" && !Array.isArray(value);
290
+ }
291
+
292
+ function tokenOfType(value: unknown, type: string): boolean {
293
+ if (typeof value !== "string") return false;
294
+ const entry = parseToken(value);
295
+ return entry !== undefined && entry.type === type;
296
+ }
297
+
298
+ /**
299
+ * Every problem in one pass. Cross-field safety rules live here rather than in
300
+ * the JSON Schema, which cannot express them readably; the schema is the
301
+ * structural half of the same contract
302
+ * (conformance/schema/scenario-0.1.schema.json).
303
+ */
304
+ export function validateScenario(value: unknown): ScenarioFinding[] {
305
+ const findings: ScenarioFinding[] = [];
306
+ const bad = (path: string, message: string) => findings.push({ path, message });
307
+
308
+ if (!isRecord(value)) return [{ path: "", message: "scenario must be a JSON object" }];
309
+
310
+ if (value.scenario !== SCENARIO_VERSION) {
311
+ bad("scenario", `must be "${SCENARIO_VERSION}"`);
312
+ }
313
+ if (typeof value.name !== "string" || !SLUG_PATTERN.test(value.name)) {
314
+ bad("name", "must be a slug: lowercase words separated by single hyphens");
315
+ }
316
+ if (value.description !== undefined && typeof value.description !== "string") {
317
+ bad("description", "must be a string");
318
+ }
319
+
320
+ const entryPoint = value.entryPoint;
321
+ if (!isRecord(entryPoint)) {
322
+ bad("entryPoint", "is required");
323
+ } else {
324
+ if (entryPoint.channel !== "voice" && entryPoint.channel !== "chat") {
325
+ bad("entryPoint.channel", 'must be "voice" or "chat"');
326
+ }
327
+ if (!tokenOfType(entryPoint.flow, "flow")) {
328
+ bad("entryPoint.flow", "must be a ${cdref:flow:...} token");
329
+ }
330
+ const phone = entryPoint.sourcePhoneNumber;
331
+ if (entryPoint.channel === "chat" && phone !== undefined) {
332
+ // One finding per field: a number that is also malformed is still just
333
+ // a number that does not belong here.
334
+ bad("entryPoint.sourcePhoneNumber", "is voice only");
335
+ } else if (phone !== undefined && (typeof phone !== "string" || !E164_PATTERN.test(phone))) {
336
+ bad("entryPoint.sourcePhoneNumber", "must be an E.164 number such as +15550100");
337
+ }
338
+ if (entryPoint.destinationPhoneNumber !== undefined) {
339
+ // Verified 2026-09-01: CreateTestCase answers "Must specify either FlowId
340
+ // or phone numbers" when VoiceCallEntryPointParameters carries both, and
341
+ // a scenario always carries its flow.
342
+ bad(
343
+ "entryPoint.destinationPhoneNumber",
344
+ "is not a scenario field: the entry point names its flow, and CreateTestCase rejects a FlowId combined with a DestinationPhoneNumber",
345
+ );
346
+ }
347
+ }
348
+
349
+ if (value.attributes !== undefined) {
350
+ if (!isRecord(value.attributes)) {
351
+ bad("attributes", "must be an object of string values");
352
+ } else {
353
+ for (const [key, attribute] of Object.entries(value.attributes)) {
354
+ if (typeof attribute !== "string") bad(`attributes.${key}`, "must be a string");
355
+ }
356
+ }
357
+ }
358
+
359
+ const steps = value.steps;
360
+ if (!Array.isArray(steps) || steps.length === 0) {
361
+ bad("steps", "must be a non-empty array");
362
+ } else {
363
+ steps.forEach((step: unknown, i) => {
364
+ const at = `steps[${String(i)}]`;
365
+ if (!isRecord(step)) {
366
+ bad(at, "must be an object");
367
+ return;
368
+ }
369
+ const kind = step.kind;
370
+ if (typeof kind !== "string" || !STEP_KINDS.has(kind)) {
371
+ bad(`${at}.kind`, `must be one of ${[...STEP_KINDS].join(", ")}`);
372
+ return;
373
+ }
374
+ switch (kind) {
375
+ case "expect-prompt": {
376
+ const hasContains = typeof step.contains === "string" && step.contains !== "";
377
+ const hasSimilar = typeof step.similarTo === "string" && step.similarTo !== "";
378
+ if (hasContains === hasSimilar) {
379
+ bad(at, "needs exactly one of contains or similarTo");
380
+ }
381
+ break;
382
+ }
383
+ case "expect-lambda":
384
+ if (!tokenOfType(step.lambda, "lambda")) {
385
+ bad(`${at}.lambda`, "must be a ${cdref:lambda:...} token");
386
+ }
387
+ break;
388
+ case "expect-transfer":
389
+ if (!tokenOfType(step.queue, "queue")) {
390
+ bad(`${at}.queue`, "must be a ${cdref:queue:...} token");
391
+ }
392
+ break;
393
+ case "expect-hours-check":
394
+ if (!tokenOfType(step.hours, "hours")) {
395
+ bad(`${at}.hours`, "must be a ${cdref:hours:...} token");
396
+ }
397
+ break;
398
+ case "expect-lex":
399
+ if (!tokenOfType(step.lex, "lex")) {
400
+ bad(`${at}.lex`, "must be a ${cdref:lex:...} token");
401
+ }
402
+ break;
403
+ case "expect-queue":
404
+ if (typeof step.name !== "string" || step.name === "") {
405
+ bad(`${at}.name`, "must be the queue name the flow reports in $.Queue.Name");
406
+ }
407
+ break;
408
+ case "assert": {
409
+ if (typeof step.path !== "string" || !JSONPATH_PATTERN.test(step.path)) {
410
+ bad(`${at}.path`, "must be a single JSONPath identifier such as $.Attributes.locale");
411
+ }
412
+ if (typeof step.operator !== "string" || !ASSERT_OPERATORS.has(step.operator)) {
413
+ bad(
414
+ `${at}.operator`,
415
+ `must be one of ${[...ASSERT_OPERATORS].join(", ")} (the documented Exists operator is rejected by CreateTestCase)`,
416
+ );
417
+ }
418
+ if (typeof step.value !== "string") {
419
+ bad(`${at}.value`, "is required: every Assert operator takes an Operand");
420
+ }
421
+ break;
422
+ }
423
+ case "send-dtmf":
424
+ if (typeof step.value !== "string" || !DTMF_PATTERN.test(step.value)) {
425
+ bad(`${at}.value`, "must be digits, * or #");
426
+ }
427
+ if (isRecord(entryPoint) && entryPoint.channel === "chat") {
428
+ // Verified 2026-09-01: "DTMF input is not supported for Chat entry point".
429
+ bad(
430
+ at,
431
+ "send-dtmf is voice only: CreateTestCase rejects DtmfInput under a CHAT entry point",
432
+ );
433
+ }
434
+ break;
435
+ case "send-speech":
436
+ case "send-text":
437
+ if (typeof step.text !== "string" || step.text === "") {
438
+ bad(`${at}.text`, "must be a non-empty string");
439
+ }
440
+ if (
441
+ kind === "send-speech" &&
442
+ step.languageCode !== undefined &&
443
+ typeof step.languageCode !== "string"
444
+ ) {
445
+ bad(`${at}.languageCode`, "must be a string such as en-US");
446
+ }
447
+ break;
448
+ default:
449
+ break;
450
+ }
451
+ });
452
+ }
453
+
454
+ if (value.substitutions !== undefined) {
455
+ if (!Array.isArray(value.substitutions)) {
456
+ bad("substitutions", "must be an array");
457
+ } else {
458
+ value.substitutions.forEach((substitution: unknown, i) => {
459
+ const at = `substitutions[${String(i)}]`;
460
+ if (!isRecord(substitution)) {
461
+ bad(at, "must be an object");
462
+ return;
463
+ }
464
+ const spec =
465
+ typeof substitution.actionType === "string" &&
466
+ Object.hasOwn(SUBSTITUTION_PARAMETER, substitution.actionType)
467
+ ? SUBSTITUTION_PARAMETER[substitution.actionType as SubstitutionActionType]
468
+ : undefined;
469
+ if (spec === undefined) {
470
+ bad(
471
+ `${at}.actionType`,
472
+ "must be TransferContactToQueue, CheckHoursOfOperation, or InvokeLambdaFunction",
473
+ );
474
+ return;
475
+ }
476
+ const token = `\${cdref:${spec.refType}:...}`;
477
+ // An override names the resource it replaces; an empty ActionParameters
478
+ // is rejected server-side (verified 2026-09-01).
479
+ const parameters = substitution.actionParameters;
480
+ if (!isRecord(parameters) || !tokenOfType(parameters[spec.key], spec.refType)) {
481
+ bad(`${at}.actionParameters`, `must carry ${spec.key} as a ${token} token`);
482
+ }
483
+ // Every value in the map is a resource reference by definition, and the
484
+ // compiler copies the map verbatim into Content, so a literal here would
485
+ // be a literal ARN in the test case. Same rule as the schema's
486
+ // additionalProperties.
487
+ if (isRecord(parameters)) {
488
+ for (const [key, parameter] of Object.entries(parameters)) {
489
+ if (!isToken(parameter)) {
490
+ bad(`${at}.actionParameters.${key}`, "must be a ${cdref:...} token, never a literal");
491
+ }
492
+ }
493
+ }
494
+ if (!tokenOfType(substitution.substitute, spec.refType)) {
495
+ bad(`${at}.substitute`, `must be a ${token} token`);
496
+ }
497
+ });
498
+ }
499
+ }
500
+
501
+ if (value.endTest !== undefined && typeof value.endTest !== "boolean") {
502
+ bad("endTest", "must be a boolean");
503
+ }
504
+
505
+ // Safety, and it is a hard rule rather than an option: a simulated contact
506
+ // that reaches a queue can connect to a live agent. AWS documents two
507
+ // mitigations, ending the test before the transfer and substituting a test
508
+ // queue, so a scenario that transfers must do one of them.
509
+ const transfers =
510
+ Array.isArray(steps) && steps.some((s: unknown) => isRecord(s) && s.kind === "expect-transfer");
511
+ const substitutesQueue =
512
+ Array.isArray(value.substitutions) &&
513
+ value.substitutions.some(
514
+ (s: unknown) => isRecord(s) && s.actionType === "TransferContactToQueue",
515
+ );
516
+ if (transfers && value.endTest === false && !substitutesQueue) {
517
+ bad(
518
+ "endTest",
519
+ "a scenario that expects a queue transfer must end the test or substitute the queue, or the simulated contact can reach a live agent",
520
+ );
521
+ }
522
+
523
+ return findings;
524
+ }
525
+
526
+ /** Validates and narrows. Throws a ScenarioValidationError listing every problem. */
527
+ export function parseScenario(value: unknown): Scenario {
528
+ const findings = validateScenario(value);
529
+ if (findings.length > 0) throw new ScenarioValidationError(findings);
530
+ return value as Scenario;
531
+ }
532
+
533
+ // --- Compilation to the Connect Testing language -----------------------------
534
+
535
+ export interface TestEvent {
536
+ Identifier: string;
537
+ Type: "TestInitiated" | "TestCompleted" | "MessageReceived" | "FlowActionStarted";
538
+ Actor: "System";
539
+ Properties: Record<string, unknown>;
540
+ }
541
+
542
+ /**
543
+ * A Connect Testing language Action.
544
+ *
545
+ * The discriminator is `Type` and the payload lives in `Parameters`; an
546
+ * earlier version of this file emitted `ActionType` at the top level with the
547
+ * payload flattened beside it, which no AWS example shows. Verified against the
548
+ * worked example, which is the only place the full envelope appears:
549
+ * https://docs.aws.amazon.com/connect/latest/devguide/testing-language-example.html
550
+ *
551
+ * Note that SendInstruction and OverrideSystemBehavior repeat `ActionType`
552
+ * INSIDE Parameters while Assert does not. That asymmetry is what the example
553
+ * shows, so it is reproduced faithfully rather than normalized.
554
+ */
555
+ export type TestActionType =
556
+ "SendInstruction" | "Assert" | "OverrideSystemBehavior" | "TestControl";
557
+
558
+ export interface TestAction {
559
+ Identifier: string;
560
+ Type: TestActionType;
561
+ Parameters: Record<string, unknown>;
562
+ Transitions: Record<string, unknown>;
563
+ }
564
+
565
+ export interface TestObservation {
566
+ Identifier: string;
567
+ Event: TestEvent;
568
+ Actions: TestAction[];
569
+ Transitions: { NextObservations: string[] };
570
+ }
571
+
572
+ export interface TestingLanguageDocument {
573
+ Version: "2019-10-30";
574
+ Metadata: Record<string, unknown>;
575
+ Observations: TestObservation[];
576
+ }
577
+
578
+ /**
579
+ * The CreateTestCase EntryPoint. The compiler never emits
580
+ * DestinationPhoneNumber (see ScenarioEntryPoint), so the type does not carry
581
+ * it.
582
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_TestCaseEntryPoint.html
583
+ */
584
+ export interface TestCaseEntryPoint {
585
+ Type: "VOICE_CALL" | "CHAT";
586
+ VoiceCallEntryPointParameters?: { SourcePhoneNumber?: string; FlowId: string };
587
+ ChatEntryPointParameters?: { FlowId: string };
588
+ }
589
+
590
+ /** Everything CreateTestCase needs, with `${cdref:...}` tokens still in place. */
591
+ export interface CompiledScenario {
592
+ name: string;
593
+ description?: string;
594
+ entryPoint: TestCaseEntryPoint;
595
+ /**
596
+ * The InitializationData JSON string. Documented only as "Defines the initial
597
+ * custom attributes for your test" and "must be a valid JSON string"; no
598
+ * example or key list is published, so the shape was found by executing
599
+ * variants against a sandbox (verified 2026-09-01):
600
+ *
601
+ * - `{"Attributes": {...}, "SegmentAttributes": {}}` is the only shape that
602
+ * read the value back: an Assert on `$.Attributes.<key>` PASSED with it.
603
+ * - A flat `{"key": "value"}` map, and the wrappers `ContactData`,
604
+ * `ContactAttributes`, `CustomAttributes` and JSONPath keys, are accepted
605
+ * by CreateTestCase but the Assert read `$.Attributes.<key>` back empty.
606
+ * - The lowercase `attributes` wrapper is accepted; its one execution failed
607
+ * on an observation before the Assert ran, so nothing is known about the
608
+ * value it produces.
609
+ * - `{"Attributes": {...}}` WITHOUT `SegmentAttributes` is accepted. Of three
610
+ * executions, one stayed INITIATED for the whole 240 s wait, and two
611
+ * started the contact but failed on an observation before the Assert ran,
612
+ * so it is not known to read back either. Both keys are always emitted
613
+ * because the two-key shape is the one shown to work.
614
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_CreateTestCase.html
615
+ */
616
+ initializationData?: string;
617
+ content: TestingLanguageDocument;
618
+ }
619
+
620
+ /** Fictional, and it matches the API's E.164 pattern (verified 2026-09-01). */
621
+ const VOICE_SOURCE_DEFAULT = "+15550100";
622
+
623
+ function eventFor(step: ScenarioStep, identifier: string): TestEvent | undefined {
624
+ switch (step.kind) {
625
+ case "expect-prompt":
626
+ return {
627
+ Identifier: identifier,
628
+ Type: "MessageReceived",
629
+ Actor: "System",
630
+ Properties:
631
+ step.contains === undefined
632
+ ? { Text: step.similarTo, MatchingCriteria: { Type: "Similarity" } }
633
+ : { Text: step.contains, MatchingCriteria: { Type: "Inclusion" } },
634
+ };
635
+ case "expect-lambda":
636
+ return {
637
+ Identifier: identifier,
638
+ Type: "FlowActionStarted",
639
+ Actor: "System",
640
+ Properties: {
641
+ ActionType: "InvokeLambdaFunction",
642
+ ActionParameters: { LambdaFunctionARN: step.lambda },
643
+ },
644
+ };
645
+ case "expect-transfer":
646
+ return {
647
+ Identifier: identifier,
648
+ Type: "FlowActionStarted",
649
+ Actor: "System",
650
+ Properties: {
651
+ ActionType: "TransferContactToQueue",
652
+ ActionParameters: { QueueId: step.queue },
653
+ },
654
+ };
655
+ case "expect-hours-check":
656
+ return {
657
+ Identifier: identifier,
658
+ Type: "FlowActionStarted",
659
+ Actor: "System",
660
+ Properties: {
661
+ ActionType: "CheckHoursOfOperation",
662
+ ActionParameters: { HoursOfOperationId: step.hours },
663
+ },
664
+ };
665
+ case "expect-lex":
666
+ return {
667
+ Identifier: identifier,
668
+ Type: "FlowActionStarted",
669
+ Actor: "System",
670
+ Properties: {
671
+ ActionType: "ConnectParticipantWithLexBot",
672
+ ActionParameters: { LexV2Bot: { AliasArn: step.lex } },
673
+ },
674
+ };
675
+ default:
676
+ return undefined;
677
+ }
678
+ }
679
+
680
+ function actionFor(step: ScenarioStep, identifier: string): TestAction | undefined {
681
+ const sendInstruction = (instruction: Record<string, unknown>): TestAction => ({
682
+ Identifier: identifier,
683
+ Type: "SendInstruction",
684
+ Parameters: { ActionType: "SendInstruction", Actor: "Customer", Instruction: instruction },
685
+ Transitions: {},
686
+ });
687
+ const assert = (parameters: Record<string, unknown>): TestAction => ({
688
+ Identifier: identifier,
689
+ Type: "Assert",
690
+ Parameters: parameters,
691
+ Transitions: {},
692
+ });
693
+
694
+ // Utterance carries its text as `Value`. The SendInstruction page documents
695
+ // `Text` and `SSML`; CreateTestCase rejects both with "Invalid test case
696
+ // content" and accepts a non-empty `Value`, with LanguageCode optional
697
+ // (verified 2026-09-01 against a sandbox instance, chat and voice).
698
+ // https://docs.aws.amazon.com/connect/latest/devguide/testing-language-actions-send-instruction.html
699
+ switch (step.kind) {
700
+ case "send-dtmf":
701
+ return sendInstruction({ Type: "DtmfInput", Properties: { Value: step.value } });
702
+ case "send-speech":
703
+ return sendInstruction({
704
+ Type: "Utterance",
705
+ Properties: { Value: step.text, LanguageCode: step.languageCode ?? "en-US" },
706
+ });
707
+ case "send-text":
708
+ return sendInstruction({ Type: "Utterance", Properties: { Value: step.text } });
709
+ case "disconnect":
710
+ // The documented Disconnect example carries no Properties member at all.
711
+ return sendInstruction({ Type: "Disconnect" });
712
+ case "expect-queue":
713
+ return assert({ Namespace: "$.Queue.Name", Operator: "Equals", Operand: step.name });
714
+ case "assert":
715
+ return assert({ Namespace: step.path, Operator: step.operator, Operand: step.value });
716
+ default:
717
+ return undefined;
718
+ }
719
+ }
720
+
721
+ function substitutionAction(substitution: ScenarioSubstitution, identifier: string): TestAction {
722
+ return {
723
+ Identifier: identifier,
724
+ Type: "OverrideSystemBehavior",
725
+ Parameters: {
726
+ ActionType: "OverrideSystemBehavior",
727
+ Behavior: {
728
+ Type: "FlowAction",
729
+ Properties: {
730
+ ActionType: substitution.actionType,
731
+ ActionParameters: substitution.actionParameters,
732
+ Strategy: { Type: "SubstituteResource", SubstituteArn: substitution.substitute },
733
+ },
734
+ },
735
+ },
736
+ Transitions: {},
737
+ };
738
+ }
739
+
740
+ /**
741
+ * Scenario in, Connect Testing language out. Deterministic: identifiers are
742
+ * positional, so the same scenario compiles to the same bytes every time.
743
+ */
744
+ export function compileScenario(scenario: Scenario): CompiledScenario {
745
+ const findings = validateScenario(scenario);
746
+ if (findings.length > 0) throw new ScenarioValidationError(findings);
747
+
748
+ const observations: TestObservation[] = [];
749
+ const open = (event: (identifier: string) => TestEvent): TestObservation => {
750
+ const index = observations.length + 1;
751
+ const identifier = `observation-${String(index)}`;
752
+ const observation: TestObservation = {
753
+ Identifier: identifier,
754
+ Event: event(`${identifier}-event`),
755
+ Actions: [],
756
+ Transitions: { NextObservations: [] },
757
+ };
758
+ observations.push(observation);
759
+ return observation;
760
+ };
761
+
762
+ // The graph always starts at TestInitiated: anything the customer does before
763
+ // the flow says something is a response to the test starting.
764
+ let current = open((identifier) => ({
765
+ Identifier: identifier,
766
+ Type: "TestInitiated",
767
+ Actor: "System",
768
+ Properties: {},
769
+ }));
770
+
771
+ // Resource substitutions apply from the first observation, before the flow
772
+ // has had a chance to transfer anywhere.
773
+ for (const substitution of scenario.substitutions ?? []) {
774
+ current.Actions.push(
775
+ substitutionAction(
776
+ substitution,
777
+ `${current.Identifier}-action-${String(current.Actions.length + 1)}`,
778
+ ),
779
+ );
780
+ }
781
+
782
+ for (const step of scenario.steps) {
783
+ // An expectation is an Event, so it opens a new Observation. Everything
784
+ // else is an Action responding to the Observation opened most recently.
785
+ const probe = eventFor(step, "probe");
786
+ if (probe !== undefined) {
787
+ current = open((identifier) => eventFor(step, identifier) as TestEvent);
788
+ continue;
789
+ }
790
+ const action = actionFor(
791
+ step,
792
+ `${current.Identifier}-action-${String(current.Actions.length + 1)}`,
793
+ );
794
+ if (action !== undefined) current.Actions.push(action);
795
+ }
796
+
797
+ if (scenario.endTest !== false) {
798
+ // EndTest is the safety default: AWS documents that a simulated contact
799
+ // which reaches a queue transfer "might reach the agent queue and connect
800
+ // with a live agent as a contact".
801
+ // https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html
802
+ current.Actions.push({
803
+ Identifier: `${current.Identifier}-action-${String(current.Actions.length + 1)}`,
804
+ Type: "TestControl",
805
+ Parameters: { ActionType: "TestControl", Command: { Type: "EndTest" } },
806
+ Transitions: {},
807
+ });
808
+ }
809
+
810
+ // Observations chain forward; actions chain within an observation.
811
+ observations.forEach((observation, i) => {
812
+ const next = observations[i + 1];
813
+ observation.Transitions = { NextObservations: next === undefined ? [] : [next.Identifier] };
814
+ observation.Actions.forEach((action, j) => {
815
+ const nextAction = observation.Actions[j + 1];
816
+ action.Transitions = nextAction === undefined ? {} : { NextAction: nextAction.Identifier };
817
+ });
818
+ });
819
+
820
+ // A voice entry point is FlowId plus SourcePhoneNumber and nothing else.
821
+ // CreateTestCase rejects FlowId combined with DestinationPhoneNumber ("Must
822
+ // specify either FlowId or phone numbers") and accepts FlowId alone or with
823
+ // SourcePhoneNumber (verified 2026-09-01); the fictional default keeps the
824
+ // simulated caller's number deterministic. Every field is optional in the
825
+ // API reference, which is why the rule had to be found empirically.
826
+ // https://docs.aws.amazon.com/connect/latest/APIReference/API_VoiceCallEntryPointParameters.html
827
+ const entryPoint: TestCaseEntryPoint =
828
+ scenario.entryPoint.channel === "chat"
829
+ ? { Type: "CHAT", ChatEntryPointParameters: { FlowId: scenario.entryPoint.flow } }
830
+ : {
831
+ Type: "VOICE_CALL",
832
+ VoiceCallEntryPointParameters: {
833
+ SourcePhoneNumber: scenario.entryPoint.sourcePhoneNumber ?? VOICE_SOURCE_DEFAULT,
834
+ FlowId: scenario.entryPoint.flow,
835
+ },
836
+ };
837
+
838
+ const compiled: CompiledScenario = {
839
+ name: scenario.name,
840
+ ...(scenario.description === undefined ? {} : { description: scenario.description }),
841
+ entryPoint,
842
+ content: { Version: "2019-10-30", Metadata: {}, Observations: observations },
843
+ };
844
+ if (scenario.attributes !== undefined && Object.keys(scenario.attributes).length > 0) {
845
+ compiled.initializationData = JSON.stringify({
846
+ Attributes: sortKeys(scenario.attributes),
847
+ SegmentAttributes: {},
848
+ });
849
+ }
850
+ return compiled;
851
+ }
852
+
853
+ /** Canonical JSON for the CreateTestCase Content string. Byte-stable. */
854
+ export function serializeTestContent(content: TestingLanguageDocument): string {
855
+ const canonical = ordered(
856
+ {
857
+ Version: content.Version,
858
+ Metadata: sortKeys(content.Metadata),
859
+ Observations: content.Observations.map((observation) =>
860
+ ordered(
861
+ {
862
+ Identifier: observation.Identifier,
863
+ Event: sortKeys(observation.Event),
864
+ Actions: observation.Actions.map((action) => sortKeys(action)),
865
+ Transitions: sortKeys(observation.Transitions),
866
+ } as Record<string, unknown>,
867
+ ["Identifier", "Event", "Actions", "Transitions"],
868
+ ),
869
+ ),
870
+ } as Record<string, unknown>,
871
+ ["Version", "Metadata", "Observations"],
872
+ );
873
+ return JSON.stringify(canonical, null, 2) + "\n";
874
+ }
875
+
876
+ function resolveDeep(value: unknown, resourceMap: Record<string, string>): unknown {
877
+ if (typeof value === "string") {
878
+ return TOKEN_PATTERN.test(value) ? (resourceMap[value] ?? value) : value;
879
+ }
880
+ if (Array.isArray(value)) return value.map((v) => resolveDeep(v, resourceMap));
881
+ if (value !== null && typeof value === "object") {
882
+ return Object.fromEntries(
883
+ Object.entries(value as Record<string, unknown>).map(([k, v]) => [
884
+ k,
885
+ resolveDeep(v, resourceMap),
886
+ ]),
887
+ );
888
+ }
889
+ return value;
890
+ }
891
+
892
+ /**
893
+ * Replaces every token in a compiled scenario with its resolved value. Strict,
894
+ * and it reports every missing token at once, exactly as materializeWithMap
895
+ * does; it throws the same MaterializeError so callers handle one type.
896
+ */
897
+ export function resolveScenario(
898
+ compiled: CompiledScenario,
899
+ resourceMap: Record<string, string>,
900
+ ): CompiledScenario {
901
+ const missing = collectRefs(compiled)
902
+ .map((entry) => entry.token)
903
+ .filter((token) => !Object.hasOwn(resourceMap, token));
904
+ if (missing.length > 0) throw new MaterializeError(missing);
905
+ return resolveDeep(compiled, resourceMap) as CompiledScenario;
906
+ }
907
+
908
+ // --- Client seam -------------------------------------------------------------
909
+
910
+ /** TestCaseExecutionStatus, verbatim from the API. */
911
+ export type TestExecutionStatus = "INITIATED" | "IN_PROGRESS" | "PASSED" | "FAILED" | "STOPPED";
912
+
913
+ export interface CreateTestCaseInput {
914
+ name: string;
915
+ description?: string;
916
+ /** The Content string: the compiled and resolved Testing language document. */
917
+ content: string;
918
+ entryPoint: TestCaseEntryPoint;
919
+ initializationData?: string;
920
+ /** PUBLISHED, because only a published test case can be executed. */
921
+ status: "PUBLISHED" | "SAVED";
922
+ }
923
+
924
+ export interface ExecutionSummary {
925
+ status: TestExecutionStatus;
926
+ startTime?: string;
927
+ endTime?: string;
928
+ observations?: { total?: number; passed?: number; failed?: number };
929
+ }
930
+
931
+ export interface ExecutionRecordSummary {
932
+ observationId?: string;
933
+ status?: "PASSED" | "FAILED" | "IN_PROGRESS" | "STOPPED";
934
+ timestamp?: string;
935
+ /**
936
+ * "The details of the executed record", documented only as a string. Treated
937
+ * as an opaque message payload; its schema is not published.
938
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_ListTestCaseExecutionRecords.html
939
+ */
940
+ record?: string;
941
+ }
942
+
943
+ /**
944
+ * The narrow seam the runner talks to. Every method maps one-to-one onto a
945
+ * TestCase operation, so a fake in a test and the SDK adapter are the same
946
+ * shape.
947
+ */
948
+ export interface FlowTestClient {
949
+ createTestCase(input: CreateTestCaseInput): Promise<{ testCaseId: string; testCaseArn?: string }>;
950
+ startExecution(
951
+ testCaseId: string,
952
+ clientToken?: string,
953
+ ): Promise<{ testCaseExecutionId: string; status: TestExecutionStatus }>;
954
+ getExecutionSummary(testCaseId: string, executionId: string): Promise<ExecutionSummary>;
955
+ listExecutionRecords(testCaseId: string, executionId: string): Promise<ExecutionRecordSummary[]>;
956
+ stopExecution(testCaseId: string, executionId: string): Promise<void>;
957
+ deleteTestCase(testCaseId: string): Promise<void>;
958
+ }
959
+
960
+ // --- Runner ------------------------------------------------------------------
961
+
962
+ export type ScenarioStatus = "PASSED" | "FAILED" | "TIMED_OUT" | "ERRORED" | "STOPPED";
963
+
964
+ export interface ScenarioResult {
965
+ name: string;
966
+ status: ScenarioStatus;
967
+ durationMs: number;
968
+ testCaseId?: string;
969
+ executionId?: string;
970
+ observations?: { total: number; passed: number; failed: number };
971
+ message?: string;
972
+ details?: string[];
973
+ }
974
+
975
+ export interface SimulationRun {
976
+ startedAt: string;
977
+ finishedAt: string;
978
+ totals: {
979
+ total: number;
980
+ passed: number;
981
+ failed: number;
982
+ timedOut: number;
983
+ errored: number;
984
+ stopped: number;
985
+ };
986
+ results: ScenarioResult[];
987
+ }
988
+
989
+ export interface RunOptions {
990
+ /** Token to value, exactly as materializeWithMap takes. */
991
+ resourceMap: Record<string, string>;
992
+ /** At most SIMULATE_LIMITS.concurrentTests. */
993
+ concurrency?: number;
994
+ /**
995
+ * Admission ceiling on executions this runner keeps in flight. Defaults to
996
+ * the documented queue capacity, which INCLUDES the running tests.
997
+ */
998
+ maxInFlight?: number;
999
+ /** Per scenario. Capped at the documented 5 minutes. */
1000
+ timeoutMs?: number;
1001
+ /**
1002
+ * Poll interval for GetTestCaseExecutionSummary. Connect's default quota is
1003
+ * 2 rps per account per Region across all operations, so polling faster than
1004
+ * this buys nothing but ThrottlingExceptions.
1005
+ * https://docs.aws.amazon.com/connect/latest/adminguide/amazon-connect-service-limits.html#connect-api-quotas
1006
+ */
1007
+ pollIntervalMs?: number;
1008
+ /** Delete each test case after its execution finishes. Default true. */
1009
+ cleanup?: boolean;
1010
+ /**
1011
+ * Extra starts per scenario when the first does not take: a
1012
+ * ServiceQuotaExceededException (HTTP 402) from StartTestCaseExecution, or an
1013
+ * execution that Connect reports FAILED with INITIALIZATION_FAILURE before it
1014
+ * observed anything. Default 3. Backoff is pollIntervalMs times the attempt.
1015
+ */
1016
+ startRetries?: number;
1017
+ /** Injected for tests. */
1018
+ now?: () => number;
1019
+ /** Injected for tests. */
1020
+ sleep?: (ms: number) => Promise<void>;
1021
+ /** Idempotency token per scenario. Default: the scenario name. */
1022
+ clientToken?: (scenario: Scenario, attempt: number) => string;
1023
+ }
1024
+
1025
+ function isQuotaExceeded(error: unknown): boolean {
1026
+ return (
1027
+ error !== null &&
1028
+ typeof error === "object" &&
1029
+ (error as { name?: unknown }).name === "ServiceQuotaExceededException"
1030
+ );
1031
+ }
1032
+
1033
+ function toIso(ms: number): string {
1034
+ return new Date(ms).toISOString();
1035
+ }
1036
+
1037
+ /**
1038
+ * The COMPLETION record of an execution that Connect failed before it observed
1039
+ * anything: `CompletionReason.FailureReasons` carries `INITIALIZATION_FAILURE`
1040
+ * and the message says why ("Failed to start execution of test case due to
1041
+ * limit reached" is the one seen live). The record's schema is not published,
1042
+ * so anything that does not parse into that shape is not an admission failure.
1043
+ * Returns the reason text, or undefined when the execution did start.
1044
+ */
1045
+ function initializationFailure(records: readonly ExecutionRecordSummary[]): string | undefined {
1046
+ for (const { record } of records) {
1047
+ if (record === undefined) continue;
1048
+ let parsed: unknown;
1049
+ try {
1050
+ parsed = JSON.parse(record);
1051
+ } catch {
1052
+ continue;
1053
+ }
1054
+ if (!isRecord(parsed) || parsed.Type !== "COMPLETION" || !isRecord(parsed.CompletionReason)) {
1055
+ continue;
1056
+ }
1057
+ const reasons = parsed.CompletionReason.FailureReasons;
1058
+ if (!Array.isArray(reasons) || !reasons.includes("INITIALIZATION_FAILURE")) continue;
1059
+ const message = parsed.CompletionReason.Message;
1060
+ return typeof message === "string" && message !== ""
1061
+ ? `INITIALIZATION_FAILURE: ${message}`
1062
+ : "INITIALIZATION_FAILURE";
1063
+ }
1064
+ return undefined;
1065
+ }
1066
+
1067
+ /**
1068
+ * Creates, publishes, executes, polls, collects, and deletes one test case per
1069
+ * scenario, within the documented limits. The whole lifecycle exists because
1070
+ * StartTestCaseExecution runs a stored, published test case and there is no
1071
+ * submit-and-run call.
1072
+ */
1073
+ export async function runScenarios(
1074
+ scenarios: readonly Scenario[],
1075
+ client: FlowTestClient,
1076
+ options: RunOptions,
1077
+ ): Promise<SimulationRun> {
1078
+ const concurrency = options.concurrency ?? SIMULATE_LIMITS.concurrentTests;
1079
+ if (concurrency < 1 || concurrency > SIMULATE_LIMITS.concurrentTests) {
1080
+ throw new Error(
1081
+ `concurrency must be between 1 and ${String(SIMULATE_LIMITS.concurrentTests)}: Amazon Connect runs at most that many tests at once and queues the rest.`,
1082
+ );
1083
+ }
1084
+ const maxInFlight = options.maxInFlight ?? SIMULATE_LIMITS.queueCapacityIncludingRunning;
1085
+ if (maxInFlight < 1 || maxInFlight > SIMULATE_LIMITS.queueCapacityIncludingRunning) {
1086
+ throw new Error(
1087
+ `maxInFlight must be between 1 and ${String(SIMULATE_LIMITS.queueCapacityIncludingRunning)}: that is the total queue depth, running tests included.`,
1088
+ );
1089
+ }
1090
+ const timeoutMs = Math.min(
1091
+ options.timeoutMs ?? SIMULATE_LIMITS.maxDurationMs,
1092
+ SIMULATE_LIMITS.maxDurationMs,
1093
+ );
1094
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
1095
+ const now = options.now ?? Date.now;
1096
+ const sleep = options.sleep ?? defaultSleep;
1097
+ const cleanup = options.cleanup !== false;
1098
+ const startRetries = options.startRetries ?? 3;
1099
+ // ClientToken makes StartTestCaseExecution idempotent, so a second start must
1100
+ // not repeat the first attempt's token or it would name the execution that
1101
+ // already failed instead of starting another. The first attempt keeps the
1102
+ // scenario name so a retried run of the same suite stays idempotent.
1103
+ // https://docs.aws.amazon.com/connect/latest/APIReference/API_StartTestCaseExecution.html
1104
+ const clientTokenFor = (scenario: Scenario, attempt: number): string =>
1105
+ options.clientToken?.(scenario, attempt) ??
1106
+ (attempt === 0 ? scenario.name : `${scenario.name}-retry-${String(attempt)}`);
1107
+
1108
+ const startedAtMs = now();
1109
+ const results = new Array<ScenarioResult>(scenarios.length);
1110
+ let cursor = 0;
1111
+ let inFlight = 0;
1112
+
1113
+ const runOne = async (scenario: Scenario): Promise<ScenarioResult> => {
1114
+ const scenarioStart = now();
1115
+ const result: ScenarioResult = { name: scenario.name, status: "ERRORED", durationMs: 0 };
1116
+ let testCaseId: string | undefined;
1117
+ let executionId: string | undefined;
1118
+
1119
+ try {
1120
+ const resolved = resolveScenario(compileScenario(scenario), options.resourceMap);
1121
+ const created = await client.createTestCase({
1122
+ name: scenario.name,
1123
+ ...(resolved.description === undefined ? {} : { description: resolved.description }),
1124
+ content: serializeTestContent(resolved.content),
1125
+ entryPoint: resolved.entryPoint,
1126
+ ...(resolved.initializationData === undefined
1127
+ ? {}
1128
+ : { initializationData: resolved.initializationData }),
1129
+ // PUBLISHED also triggers server-side validation of Content, which is
1130
+ // where a malformed Testing language document is caught.
1131
+ status: "PUBLISHED",
1132
+ });
1133
+ testCaseId = created.testCaseId;
1134
+ result.testCaseId = testCaseId;
1135
+
1136
+ // One retry budget covers both ways a start can fail: synchronously with
1137
+ // a 402, and asynchronously with an execution that Connect reports
1138
+ // FAILED before it observed anything (INITIALIZATION_FAILURE, "Failed to
1139
+ // start execution of test case due to limit reached", seen live when
1140
+ // several voice simulations ran back to back). Neither is a verdict on
1141
+ // the scenario, so the same test case is started again.
1142
+ let attempt = 0;
1143
+ for (;;) {
1144
+ let started: { testCaseExecutionId: string; status: TestExecutionStatus } | undefined;
1145
+ while (started === undefined) {
1146
+ try {
1147
+ started = await client.startExecution(testCaseId, clientTokenFor(scenario, attempt));
1148
+ } catch (error) {
1149
+ // 402 means the 100-deep execution queue is full. Back off; it is
1150
+ // the one start error that clears on its own.
1151
+ if (!isQuotaExceeded(error) || attempt >= startRetries) throw error;
1152
+ attempt += 1;
1153
+ await sleep(pollIntervalMs * attempt);
1154
+ }
1155
+ }
1156
+
1157
+ executionId = started.testCaseExecutionId;
1158
+ result.executionId = executionId;
1159
+ // Each attempt is its own execution. The counts of one that did not
1160
+ // start ({0, 0, 0}) must not stand in for the next one when Connect
1161
+ // reaches a terminal status without reporting a summary, or the result
1162
+ // would read "0 of 0 observations failed" for an execution that ran.
1163
+ delete result.observations;
1164
+ let status: TestExecutionStatus = started.status;
1165
+ let summary: ExecutionSummary | undefined;
1166
+ let timedOut = false;
1167
+
1168
+ while (status === "INITIATED" || status === "IN_PROGRESS") {
1169
+ if (now() - scenarioStart >= timeoutMs) {
1170
+ timedOut = true;
1171
+ try {
1172
+ await client.stopExecution(testCaseId, executionId);
1173
+ } catch {
1174
+ // Best effort: Connect fails the execution at 5 minutes anyway.
1175
+ }
1176
+ break;
1177
+ }
1178
+ await sleep(pollIntervalMs);
1179
+ summary = await client.getExecutionSummary(testCaseId, executionId);
1180
+ status = summary.status;
1181
+ }
1182
+
1183
+ if (summary?.observations !== undefined) {
1184
+ result.observations = {
1185
+ total: summary.observations.total ?? 0,
1186
+ passed: summary.observations.passed ?? 0,
1187
+ failed: summary.observations.failed ?? 0,
1188
+ };
1189
+ }
1190
+
1191
+ if (timedOut) {
1192
+ result.status = "TIMED_OUT";
1193
+ 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.`;
1194
+ } else if (status === "PASSED") {
1195
+ result.status = "PASSED";
1196
+ } else if (status === "STOPPED") {
1197
+ result.status = "STOPPED";
1198
+ result.message = "Execution was stopped.";
1199
+ } else {
1200
+ const records = await client.listExecutionRecords(testCaseId, executionId);
1201
+ const notStarted = initializationFailure(records);
1202
+ if (notStarted !== undefined && attempt < startRetries) {
1203
+ attempt += 1;
1204
+ result.details = [
1205
+ ...(result.details ?? []),
1206
+ `Execution ${executionId} did not start (${notStarted}); started again.`,
1207
+ ];
1208
+ await sleep(pollIntervalMs * attempt);
1209
+ continue;
1210
+ }
1211
+ result.status = "FAILED";
1212
+ const failed = records.filter((record) => record.status === "FAILED");
1213
+ result.details = [
1214
+ ...(result.details ?? []),
1215
+ ...failed
1216
+ .map((record) => record.record ?? record.observationId ?? "")
1217
+ .filter((detail) => detail !== ""),
1218
+ ];
1219
+ result.message =
1220
+ notStarted !== undefined
1221
+ ? `Amazon Connect did not start the execution (${notStarted}); ${String(attempt + 1)} attempt(s). The scenario was not evaluated.`
1222
+ : result.observations === undefined
1223
+ ? "Execution failed."
1224
+ : `${String(result.observations.failed)} of ${String(result.observations.total)} observations failed.`;
1225
+ }
1226
+ break;
1227
+ }
1228
+ } catch (error) {
1229
+ result.status = "ERRORED";
1230
+ result.message = error instanceof Error ? error.message : String(error);
1231
+ } finally {
1232
+ if (cleanup && testCaseId !== undefined) {
1233
+ try {
1234
+ await client.deleteTestCase(testCaseId);
1235
+ } catch (error) {
1236
+ // A leaked test case is a nuisance, not a failed scenario, but it is
1237
+ // instance state so it must be visible.
1238
+ result.details = [
1239
+ ...(result.details ?? []),
1240
+ `Cleanup failed for test case ${testCaseId}: ${error instanceof Error ? error.message : String(error)}`,
1241
+ ];
1242
+ }
1243
+ }
1244
+ result.durationMs = now() - scenarioStart;
1245
+ }
1246
+ return result;
1247
+ };
1248
+
1249
+ const worker = async (): Promise<void> => {
1250
+ for (;;) {
1251
+ const index = cursor;
1252
+ cursor += 1;
1253
+ if (index >= scenarios.length) return;
1254
+ const scenario = scenarios[index];
1255
+ if (scenario === undefined) return;
1256
+ // Admission control. The runner never has more executions in flight than
1257
+ // the instance-wide queue accepts, even when a caller shares the account
1258
+ // with other runners and lowers the ceiling.
1259
+ while (inFlight >= maxInFlight) await sleep(pollIntervalMs);
1260
+ inFlight += 1;
1261
+ try {
1262
+ results[index] = await runOne(scenario);
1263
+ } finally {
1264
+ inFlight -= 1;
1265
+ }
1266
+ }
1267
+ };
1268
+
1269
+ await Promise.all(
1270
+ Array.from({ length: Math.min(concurrency, scenarios.length) }, () => worker()),
1271
+ );
1272
+
1273
+ const finishedAtMs = now();
1274
+ const finished = [...results].filter((result): result is ScenarioResult => result !== undefined);
1275
+ const count = (status: ScenarioStatus) => finished.filter((r) => r.status === status).length;
1276
+
1277
+ return {
1278
+ startedAt: toIso(startedAtMs),
1279
+ finishedAt: toIso(finishedAtMs),
1280
+ totals: {
1281
+ total: finished.length,
1282
+ passed: count("PASSED"),
1283
+ failed: count("FAILED"),
1284
+ timedOut: count("TIMED_OUT"),
1285
+ errored: count("ERRORED"),
1286
+ stopped: count("STOPPED"),
1287
+ },
1288
+ results: finished,
1289
+ };
1290
+ }
1291
+
1292
+ // --- Reporters ---------------------------------------------------------------
1293
+
1294
+ /** Deterministic JSON report. Fixed key order, two-space indent, trailing newline. */
1295
+ export function jsonReport(run: SimulationRun): string {
1296
+ const report = ordered(
1297
+ {
1298
+ schema: "flow-simulate-report/0.1",
1299
+ startedAt: run.startedAt,
1300
+ finishedAt: run.finishedAt,
1301
+ totals: ordered({ ...run.totals } as unknown as Record<string, unknown>, [
1302
+ "total",
1303
+ "passed",
1304
+ "failed",
1305
+ "timedOut",
1306
+ "errored",
1307
+ "stopped",
1308
+ ]),
1309
+ results: run.results.map((result) =>
1310
+ ordered({ ...result } as unknown as Record<string, unknown>, [
1311
+ "name",
1312
+ "status",
1313
+ "durationMs",
1314
+ "testCaseId",
1315
+ "executionId",
1316
+ "observations",
1317
+ "message",
1318
+ "details",
1319
+ ]),
1320
+ ),
1321
+ } as Record<string, unknown>,
1322
+ ["schema", "startedAt", "finishedAt", "totals", "results"],
1323
+ );
1324
+ return JSON.stringify(report, null, 2) + "\n";
1325
+ }
1326
+
1327
+ /**
1328
+ * XML 1.0 permits only tab, LF, CR and >= U+0020 as characters. `details` is
1329
+ * filled verbatim from ExecutionRecord.Record, an opaque server-supplied
1330
+ * string, so any other C0 control byte would produce a document no parser will
1331
+ * read. Those are replaced with U+FFFD rather than escaped, because numeric
1332
+ * character references to forbidden code points are themselves invalid XML.
1333
+ *
1334
+ * Tab, LF and CR are legal as content but are normalized away inside attribute
1335
+ * values, so they are emitted as character references to survive a round trip.
1336
+ */
1337
+ // eslint-disable-next-line no-control-regex
1338
+ const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g;
1339
+
1340
+ function escapeXml(value: string): string {
1341
+ return value
1342
+ .replace(XML_FORBIDDEN, "\uFFFD")
1343
+ .replace(/&/g, "&amp;")
1344
+ .replace(/</g, "&lt;")
1345
+ .replace(/>/g, "&gt;")
1346
+ .replace(/"/g, "&quot;")
1347
+ .replace(/'/g, "&apos;")
1348
+ .replace(/\t/g, "&#9;")
1349
+ .replace(/\n/g, "&#10;")
1350
+ .replace(/\r/g, "&#13;");
1351
+ }
1352
+
1353
+ function seconds(ms: number): string {
1354
+ return (ms / 1000).toFixed(3);
1355
+ }
1356
+
1357
+ export interface JUnitOptions {
1358
+ /** Suite name and testcase classname. Default "flow-simulate". */
1359
+ suiteName?: string;
1360
+ }
1361
+
1362
+ /**
1363
+ * JUnit XML, deterministic for the same run: fixed attribute order, three
1364
+ * decimal places, results in the order the suite declared them.
1365
+ */
1366
+ export function junitReport(run: SimulationRun, options: JUnitOptions = {}): string {
1367
+ const suiteName = options.suiteName ?? "flow-simulate";
1368
+ const failures = run.totals.failed + run.totals.timedOut;
1369
+ const time = seconds(run.results.reduce((sum, result) => sum + result.durationMs, 0));
1370
+ const attributes =
1371
+ `name="${escapeXml(suiteName)}" tests="${String(run.totals.total)}" ` +
1372
+ `failures="${String(failures)}" errors="${String(run.totals.errored)}" ` +
1373
+ `skipped="${String(run.totals.stopped)}" time="${time}"`;
1374
+
1375
+ const lines: string[] = [
1376
+ '<?xml version="1.0" encoding="UTF-8"?>',
1377
+ `<testsuites ${attributes}>`,
1378
+ ` <testsuite ${attributes} timestamp="${run.startedAt}">`,
1379
+ ];
1380
+
1381
+ for (const result of run.results) {
1382
+ const head = ` <testcase name="${escapeXml(result.name)}" classname="${escapeXml(suiteName)}" time="${seconds(result.durationMs)}"`;
1383
+ if (result.status === "PASSED") {
1384
+ lines.push(`${head} />`);
1385
+ continue;
1386
+ }
1387
+ const body = [result.message ?? "", ...(result.details ?? [])]
1388
+ .filter((s) => s !== "")
1389
+ .join("\n");
1390
+ const message = escapeXml(result.message ?? result.status);
1391
+ lines.push(`${head}>`);
1392
+ if (result.status === "ERRORED") {
1393
+ lines.push(
1394
+ ` <error message="${message}" type="${result.status}">${escapeXml(body)}</error>`,
1395
+ );
1396
+ } else if (result.status === "STOPPED") {
1397
+ lines.push(` <skipped message="${message}" />`);
1398
+ } else {
1399
+ lines.push(
1400
+ ` <failure message="${message}" type="${result.status}">${escapeXml(body)}</failure>`,
1401
+ );
1402
+ }
1403
+ lines.push(" </testcase>");
1404
+ }
1405
+
1406
+ lines.push(" </testsuite>", "</testsuites>");
1407
+ return lines.join("\n") + "\n";
1408
+ }
1409
+
1410
+ // --- AWS SDK adapter ---------------------------------------------------------
1411
+ // Verified 2026-09-01 against a sandbox instance in us-west-2 with
1412
+ // @aws-sdk/client-connect 3.1122.0: the env-gated integration test ran the
1413
+ // three-scenario suite through create, start, poll, list records, and delete.
1414
+ // StopTestCaseExecution was not reached live (no scenario hit the harness
1415
+ // timeout); the offline timeout test drives it through a fake client only.
1416
+ // There is no local evaluator for the Testing language, so the integration
1417
+ // test is still the only thing that exercises this adapter against Connect.
1418
+ // See tasks/A06-export-and-simulate.md.
1419
+
1420
+ /**
1421
+ * Folds the server's per-problem findings into the error message. The JS SDK
1422
+ * exposes them as `problemDetails: [{ message }]` on InvalidTestCaseException;
1423
+ * the API reference names the field `Problems`. Without this the runner reports
1424
+ * the bare "Invalid test case content " and nothing to act on.
1425
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_CreateTestCase.html
1426
+ */
1427
+ export function describeTestCaseError(error: unknown): Error {
1428
+ if (!(error instanceof Error)) return new Error(String(error));
1429
+ const details = (error as { problemDetails?: unknown }).problemDetails;
1430
+ if (!Array.isArray(details) || details.length === 0) return error;
1431
+ const messages = details
1432
+ .map((d: unknown) => (isRecord(d) && typeof d.message === "string" ? d.message : ""))
1433
+ .filter((m) => m !== "");
1434
+ if (messages.length === 0) return error;
1435
+ const described = new Error(`${error.message.trim()}: ${messages.join("; ")}`, { cause: error });
1436
+ described.name = error.name;
1437
+ return described;
1438
+ }
1439
+
1440
+ interface TestCaseCommands {
1441
+ CreateTestCaseCommand: new (input: any) => any;
1442
+ StartTestCaseExecutionCommand: new (input: any) => any;
1443
+ GetTestCaseExecutionSummaryCommand: new (input: any) => any;
1444
+ ListTestCaseExecutionRecordsCommand: new (input: any) => any;
1445
+ StopTestCaseExecutionCommand: new (input: any) => any;
1446
+ DeleteTestCaseCommand: new (input: any) => any;
1447
+ }
1448
+
1449
+ const REQUIRED_COMMANDS = [
1450
+ "CreateTestCaseCommand",
1451
+ "StartTestCaseExecutionCommand",
1452
+ "GetTestCaseExecutionSummaryCommand",
1453
+ "ListTestCaseExecutionRecordsCommand",
1454
+ "StopTestCaseExecutionCommand",
1455
+ "DeleteTestCaseCommand",
1456
+ ] as const;
1457
+
1458
+ async function loadTestCaseCommands(): Promise<TestCaseCommands> {
1459
+ let module: Record<string, unknown>;
1460
+ try {
1461
+ module = (await import("@aws-sdk/client-connect")) as unknown as Record<string, unknown>;
1462
+ } catch (cause) {
1463
+ throw new Error(
1464
+ "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).",
1465
+ { cause },
1466
+ );
1467
+ }
1468
+ const missing = REQUIRED_COMMANDS.filter((name) => typeof module[name] !== "function");
1469
+ if (missing.length > 0) {
1470
+ throw new Error(
1471
+ `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).`,
1472
+ );
1473
+ }
1474
+ return module as unknown as TestCaseCommands;
1475
+ }
1476
+
1477
+ export interface ConnectTestClientOptions extends RateLimiterOptions {
1478
+ /** An @aws-sdk/client-connect ConnectClient, or anything with `send`. */
1479
+ connect: AwsCommandSender;
1480
+ /** Instance id or instance ARN. */
1481
+ instanceId: string;
1482
+ }
1483
+
1484
+ /**
1485
+ * FlowTestClient over the AWS SDK. Rate limiting and the response-field naming
1486
+ * differences live here; the runner sees neither.
1487
+ */
1488
+ export function createConnectTestClient(options: ConnectTestClientOptions): FlowTestClient {
1489
+ const { connect, instanceId } = options;
1490
+ let commands: TestCaseCommands | undefined;
1491
+ const throttle = createRateLimiter(options);
1492
+
1493
+ const send = async (make: (c: TestCaseCommands) => any): Promise<any> => {
1494
+ commands ??= await loadTestCaseCommands();
1495
+ await throttle();
1496
+ return connect.send(make(commands));
1497
+ };
1498
+
1499
+ const iso = (value: unknown): string | undefined =>
1500
+ value instanceof Date ? value.toISOString() : typeof value === "string" ? value : undefined;
1501
+
1502
+ return {
1503
+ createTestCase: async (input) => {
1504
+ let response: any;
1505
+ try {
1506
+ response = await send(
1507
+ (c) =>
1508
+ new c.CreateTestCaseCommand({
1509
+ InstanceId: instanceId,
1510
+ Name: input.name,
1511
+ Description: input.description,
1512
+ Content: input.content,
1513
+ EntryPoint: input.entryPoint,
1514
+ InitializationData: input.initializationData,
1515
+ Status: input.status,
1516
+ }),
1517
+ );
1518
+ } catch (error) {
1519
+ throw describeTestCaseError(error);
1520
+ }
1521
+ return { testCaseId: response.TestCaseId ?? "", testCaseArn: response.TestCaseArn };
1522
+ },
1523
+
1524
+ startExecution: async (testCaseId, clientToken) => {
1525
+ const response = await send(
1526
+ (c) =>
1527
+ new c.StartTestCaseExecutionCommand({
1528
+ InstanceId: instanceId,
1529
+ TestCaseId: testCaseId,
1530
+ ClientToken: clientToken,
1531
+ }),
1532
+ );
1533
+ return {
1534
+ testCaseExecutionId: response.TestCaseExecutionId ?? "",
1535
+ status: (response.Status ?? "INITIATED") as TestExecutionStatus,
1536
+ };
1537
+ },
1538
+
1539
+ getExecutionSummary: async (testCaseId, executionId) => {
1540
+ const response = await send(
1541
+ (c) =>
1542
+ new c.GetTestCaseExecutionSummaryCommand({
1543
+ InstanceId: instanceId,
1544
+ TestCaseId: testCaseId,
1545
+ TestCaseExecutionId: executionId,
1546
+ }),
1547
+ );
1548
+ const summary: ExecutionSummary = {
1549
+ status: (response.Status ?? "IN_PROGRESS") as TestExecutionStatus,
1550
+ };
1551
+ const startTime = iso(response.StartTime);
1552
+ const endTime = iso(response.EndTime);
1553
+ if (startTime !== undefined) summary.startTime = startTime;
1554
+ if (endTime !== undefined) summary.endTime = endTime;
1555
+ if (response.ObservationSummary !== undefined) {
1556
+ summary.observations = {
1557
+ total: response.ObservationSummary.TotalObservations,
1558
+ passed: response.ObservationSummary.ObservationsPassed,
1559
+ failed: response.ObservationSummary.ObservationsFailed,
1560
+ };
1561
+ }
1562
+ return summary;
1563
+ },
1564
+
1565
+ listExecutionRecords: async (testCaseId, executionId) => {
1566
+ const out: ExecutionRecordSummary[] = [];
1567
+ let nextToken: string | undefined;
1568
+ do {
1569
+ const response = await send(
1570
+ (c) =>
1571
+ new c.ListTestCaseExecutionRecordsCommand({
1572
+ InstanceId: instanceId,
1573
+ TestCaseId: testCaseId,
1574
+ TestCaseExecutionId: executionId,
1575
+ MaxResults: 100,
1576
+ NextToken: nextToken,
1577
+ }),
1578
+ );
1579
+ for (const record of response.ExecutionRecords ?? []) {
1580
+ out.push({
1581
+ observationId: record.ObservationId,
1582
+ status: record.Status,
1583
+ timestamp: iso(record.Timestamp),
1584
+ record: record.Record,
1585
+ });
1586
+ }
1587
+ nextToken =
1588
+ response.NextToken === "" ? undefined : (response.NextToken as string | undefined);
1589
+ } while (nextToken !== undefined);
1590
+ return out;
1591
+ },
1592
+
1593
+ stopExecution: async (testCaseId, executionId) => {
1594
+ await send(
1595
+ (c) =>
1596
+ new c.StopTestCaseExecutionCommand({
1597
+ InstanceId: instanceId,
1598
+ TestCaseId: testCaseId,
1599
+ TestCaseExecutionId: executionId,
1600
+ }),
1601
+ );
1602
+ },
1603
+
1604
+ deleteTestCase: async (testCaseId) => {
1605
+ await send(
1606
+ (c) => new c.DeleteTestCaseCommand({ InstanceId: instanceId, TestCaseId: testCaseId }),
1607
+ );
1608
+ },
1609
+ };
1610
+ }