@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.
- package/LICENSE +202 -0
- package/README.md +30 -0
- package/SPEC.md +92 -0
- package/dist/actions.d.ts +87 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +204 -0
- package/dist/actions.js.map +1 -0
- package/dist/aws.d.ts +27 -0
- package/dist/aws.d.ts.map +1 -0
- package/dist/aws.js +32 -0
- package/dist/aws.js.map +1 -0
- package/dist/blocks.d.ts +287 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +352 -0
- package/dist/blocks.js.map +1 -0
- package/dist/codegen.d.ts +21 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +1027 -0
- package/dist/codegen.js.map +1 -0
- package/dist/export.d.ts +256 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +799 -0
- package/dist/export.js.map +1 -0
- package/dist/flow.d.ts +41 -0
- package/dist/flow.d.ts.map +1 -0
- package/dist/flow.js +85 -0
- package/dist/flow.js.map +1 -0
- package/dist/flowdoc.d.ts +111 -0
- package/dist/flowdoc.d.ts.map +1 -0
- package/dist/flowdoc.js +139 -0
- package/dist/flowdoc.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +11 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +49 -0
- package/dist/layout.js.map +1 -0
- package/dist/lint/engine.d.ts +16 -0
- package/dist/lint/engine.d.ts.map +1 -0
- package/dist/lint/engine.js +37 -0
- package/dist/lint/engine.js.map +1 -0
- package/dist/lint/graph.d.ts +34 -0
- package/dist/lint/graph.d.ts.map +1 -0
- package/dist/lint/graph.js +79 -0
- package/dist/lint/graph.js.map +1 -0
- package/dist/lint/index.d.ts +7 -0
- package/dist/lint/index.d.ts.map +1 -0
- package/dist/lint/index.js +5 -0
- package/dist/lint/index.js.map +1 -0
- package/dist/lint/reporters.d.ts +6 -0
- package/dist/lint/reporters.d.ts.map +1 -0
- package/dist/lint/reporters.js +30 -0
- package/dist/lint/reporters.js.map +1 -0
- package/dist/lint/rules/action-allowed-in-flow-type.d.ts +11 -0
- package/dist/lint/rules/action-allowed-in-flow-type.d.ts.map +1 -0
- package/dist/lint/rules/action-allowed-in-flow-type.js +32 -0
- package/dist/lint/rules/action-allowed-in-flow-type.js.map +1 -0
- package/dist/lint/rules/action-count.d.ts +16 -0
- package/dist/lint/rules/action-count.d.ts.map +1 -0
- package/dist/lint/rules/action-count.js +32 -0
- package/dist/lint/rules/action-count.js.map +1 -0
- package/dist/lint/rules/error-branches.d.ts +10 -0
- package/dist/lint/rules/error-branches.d.ts.map +1 -0
- package/dist/lint/rules/error-branches.js +39 -0
- package/dist/lint/rules/error-branches.js.map +1 -0
- package/dist/lint/rules/index.d.ts +17 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/index.js +36 -0
- package/dist/lint/rules/index.js.map +1 -0
- package/dist/lint/rules/module-depth-5.d.ts +14 -0
- package/dist/lint/rules/module-depth-5.d.ts.map +1 -0
- package/dist/lint/rules/module-depth-5.js +67 -0
- package/dist/lint/rules/module-depth-5.js.map +1 -0
- package/dist/lint/rules/no-literal-arn.d.ts +21 -0
- package/dist/lint/rules/no-literal-arn.d.ts.map +1 -0
- package/dist/lint/rules/no-literal-arn.js +58 -0
- package/dist/lint/rules/no-literal-arn.js.map +1 -0
- package/dist/lint/rules/no-unresolved-token.d.ts +8 -0
- package/dist/lint/rules/no-unresolved-token.d.ts.map +1 -0
- package/dist/lint/rules/no-unresolved-token.js +71 -0
- package/dist/lint/rules/no-unresolved-token.js.map +1 -0
- package/dist/lint/rules/prompt-length-3000.d.ts +16 -0
- package/dist/lint/rules/prompt-length-3000.d.ts.map +1 -0
- package/dist/lint/rules/prompt-length-3000.js +61 -0
- package/dist/lint/rules/prompt-length-3000.js.map +1 -0
- package/dist/lint/rules/reachable-blocks.d.ts +7 -0
- package/dist/lint/rules/reachable-blocks.d.ts.map +1 -0
- package/dist/lint/rules/reachable-blocks.js +45 -0
- package/dist/lint/rules/reachable-blocks.js.map +1 -0
- package/dist/lint/rules/recording-consent-before-record.d.ts +3 -0
- package/dist/lint/rules/recording-consent-before-record.d.ts.map +1 -0
- package/dist/lint/rules/recording-consent-before-record.js +87 -0
- package/dist/lint/rules/recording-consent-before-record.js.map +1 -0
- package/dist/lint/rules/terminal-blocks.d.ts +8 -0
- package/dist/lint/rules/terminal-blocks.d.ts.map +1 -0
- package/dist/lint/rules/terminal-blocks.js +40 -0
- package/dist/lint/rules/terminal-blocks.js.map +1 -0
- package/dist/lint/rules/unique-names.d.ts +7 -0
- package/dist/lint/rules/unique-names.d.ts.map +1 -0
- package/dist/lint/rules/unique-names.js +40 -0
- package/dist/lint/rules/unique-names.js.map +1 -0
- package/dist/lint/types.d.ts +30 -0
- package/dist/lint/types.d.ts.map +1 -0
- package/dist/lint/types.js +11 -0
- package/dist/lint/types.js.map +1 -0
- package/dist/materialize.d.ts +31 -0
- package/dist/materialize.d.ts.map +1 -0
- package/dist/materialize.js +192 -0
- package/dist/materialize.js.map +1 -0
- package/dist/package-names.d.ts +11 -0
- package/dist/package-names.d.ts.map +1 -0
- package/dist/package-names.js +25 -0
- package/dist/package-names.js.map +1 -0
- package/dist/refs.d.ts +35 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +69 -0
- package/dist/refs.js.map +1 -0
- package/dist/serialize.d.ts +13 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +86 -0
- package/dist/serialize.js.map +1 -0
- package/dist/simulate.d.ts +446 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +1115 -0
- package/dist/simulate.js.map +1 -0
- package/dist/synth.d.ts +25 -0
- package/dist/synth.d.ts.map +1 -0
- package/dist/synth.js +93 -0
- package/dist/synth.js.map +1 -0
- package/package.json +73 -0
- package/src/actions.ts +221 -0
- package/src/aws.ts +55 -0
- package/src/blocks.ts +619 -0
- package/src/codegen.ts +1144 -0
- package/src/export.ts +1190 -0
- package/src/flow.ts +108 -0
- package/src/flowdoc.ts +261 -0
- package/src/index.ts +31 -0
- package/src/layout.ts +54 -0
- package/src/lint/engine.ts +56 -0
- package/src/lint/graph.ts +86 -0
- package/src/lint/index.ts +10 -0
- package/src/lint/reporters.ts +44 -0
- package/src/lint/rules/action-allowed-in-flow-type.ts +32 -0
- package/src/lint/rules/action-count.ts +35 -0
- package/src/lint/rules/error-branches.ts +45 -0
- package/src/lint/rules/index.ts +53 -0
- package/src/lint/rules/module-depth-5.ts +70 -0
- package/src/lint/rules/no-literal-arn.ts +64 -0
- package/src/lint/rules/no-unresolved-token.ts +80 -0
- package/src/lint/rules/prompt-length-3000.ts +66 -0
- package/src/lint/rules/reachable-blocks.ts +50 -0
- package/src/lint/rules/recording-consent-before-record.ts +90 -0
- package/src/lint/rules/terminal-blocks.ts +45 -0
- package/src/lint/rules/unique-names.ts +44 -0
- package/src/lint/types.ts +43 -0
- package/src/materialize.ts +230 -0
- package/src/package-names.ts +26 -0
- package/src/refs.ts +93 -0
- package/src/serialize.ts +97 -0
- package/src/simulate.ts +1610 -0
- package/src/synth.ts +110 -0
package/src/simulate.ts
ADDED
|
@@ -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, "&")
|
|
1344
|
+
.replace(/</g, "<")
|
|
1345
|
+
.replace(/>/g, ">")
|
|
1346
|
+
.replace(/"/g, """)
|
|
1347
|
+
.replace(/'/g, "'")
|
|
1348
|
+
.replace(/\t/g, "	")
|
|
1349
|
+
.replace(/\n/g, " ")
|
|
1350
|
+
.replace(/\r/g, " ");
|
|
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
|
+
}
|