@agiledigital/pingone-aic-script-tester 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 (169) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +274 -0
  4. package/dist/bin/fetch-jar.d.ts +2 -0
  5. package/dist/bin/fetch-jar.js +13 -0
  6. package/dist/bin/pull-profile.d.ts +2 -0
  7. package/dist/bin/pull-profile.js +40 -0
  8. package/dist/bin/show-log.d.ts +2 -0
  9. package/dist/bin/show-log.js +31 -0
  10. package/dist/classes/.complete +12 -0
  11. package/dist/classes/AmClassShutter.class +0 -0
  12. package/dist/classes/AmContextFactory$ObservedContext.class +0 -0
  13. package/dist/classes/AmContextFactory.class +0 -0
  14. package/dist/classes/AmScopes.class +0 -0
  15. package/dist/classes/HostOps.class +0 -0
  16. package/dist/classes/JsValues.class +0 -0
  17. package/dist/classes/Json$Parser.class +0 -0
  18. package/dist/classes/Json.class +0 -0
  19. package/dist/classes/LanguageVersions.class +0 -0
  20. package/dist/classes/Runner.class +0 -0
  21. package/dist/classes/ScriptContextScope.class +0 -0
  22. package/dist/src/aic/callbacks.d.ts +35 -0
  23. package/dist/src/aic/callbacks.js +130 -0
  24. package/dist/src/aic/conform.d.ts +90 -0
  25. package/dist/src/aic/conform.js +193 -0
  26. package/dist/src/aic/constants.d.ts +13 -0
  27. package/dist/src/aic/constants.js +13 -0
  28. package/dist/src/aic/diff.d.ts +25 -0
  29. package/dist/src/aic/diff.js +201 -0
  30. package/dist/src/aic/emit-journey.d.ts +64 -0
  31. package/dist/src/aic/emit-journey.js +254 -0
  32. package/dist/src/aic/emit-result.d.ts +10 -0
  33. package/dist/src/aic/emit-result.js +39 -0
  34. package/dist/src/aic/emit-session.d.ts +26 -0
  35. package/dist/src/aic/emit-session.js +132 -0
  36. package/dist/src/aic/emit-subject.d.ts +40 -0
  37. package/dist/src/aic/emit-subject.js +210 -0
  38. package/dist/src/aic/file-lease.d.ts +35 -0
  39. package/dist/src/aic/file-lease.js +543 -0
  40. package/dist/src/aic/http.d.ts +21 -0
  41. package/dist/src/aic/http.js +127 -0
  42. package/dist/src/aic/idm.d.ts +10 -0
  43. package/dist/src/aic/idm.js +112 -0
  44. package/dist/src/aic/index.d.ts +35 -0
  45. package/dist/src/aic/index.js +21 -0
  46. package/dist/src/aic/js.d.ts +8 -0
  47. package/dist/src/aic/js.js +11 -0
  48. package/dist/src/aic/lease-identity.d.ts +38 -0
  49. package/dist/src/aic/lease-identity.js +72 -0
  50. package/dist/src/aic/lease-lock.d.ts +45 -0
  51. package/dist/src/aic/lease-lock.js +173 -0
  52. package/dist/src/aic/managed.d.ts +28 -0
  53. package/dist/src/aic/managed.js +199 -0
  54. package/dist/src/aic/provider.d.ts +156 -0
  55. package/dist/src/aic/provider.js +416 -0
  56. package/dist/src/aic/record.d.ts +28 -0
  57. package/dist/src/aic/record.js +183 -0
  58. package/dist/src/aic/resource-snapshot.d.ts +12 -0
  59. package/dist/src/aic/resource-snapshot.js +116 -0
  60. package/dist/src/aic/run.d.ts +102 -0
  61. package/dist/src/aic/run.js +450 -0
  62. package/dist/src/aic/tenant.d.ts +107 -0
  63. package/dist/src/aic/tenant.js +307 -0
  64. package/dist/src/aic/trace.d.ts +25 -0
  65. package/dist/src/aic/trace.js +56 -0
  66. package/dist/src/aic/txid.d.ts +11 -0
  67. package/dist/src/aic/txid.js +25 -0
  68. package/dist/src/aic/unsupported.d.ts +15 -0
  69. package/dist/src/aic/unsupported.js +60 -0
  70. package/dist/src/bindings/allowlist.d.ts +42 -0
  71. package/dist/src/bindings/allowlist.js +62 -0
  72. package/dist/src/bindings/harvest.d.ts +7 -0
  73. package/dist/src/bindings/harvest.js +137 -0
  74. package/dist/src/bindings/index.d.ts +4 -0
  75. package/dist/src/bindings/index.js +3 -0
  76. package/dist/src/bindings/preamble.d.ts +23 -0
  77. package/dist/src/bindings/preamble.js +36 -0
  78. package/dist/src/bindings/run.d.ts +33 -0
  79. package/dist/src/bindings/run.js +55 -0
  80. package/dist/src/case/equal.d.ts +2 -0
  81. package/dist/src/case/equal.js +40 -0
  82. package/dist/src/case/index.d.ts +5 -0
  83. package/dist/src/case/index.js +4 -0
  84. package/dist/src/case/portable.d.ts +10 -0
  85. package/dist/src/case/portable.js +28 -0
  86. package/dist/src/case/session.d.ts +34 -0
  87. package/dist/src/case/session.js +66 -0
  88. package/dist/src/case/state.d.ts +4 -0
  89. package/dist/src/case/state.js +47 -0
  90. package/dist/src/case/types.d.ts +266 -0
  91. package/dist/src/case/types.js +106 -0
  92. package/dist/src/case/util.d.ts +7 -0
  93. package/dist/src/case/util.js +115 -0
  94. package/dist/src/case/validate.d.ts +8 -0
  95. package/dist/src/case/validate.js +463 -0
  96. package/dist/src/case/verdict.d.ts +6 -0
  97. package/dist/src/case/verdict.js +810 -0
  98. package/dist/src/diagnostics.d.ts +7 -0
  99. package/dist/src/diagnostics.js +5 -0
  100. package/dist/src/emit-dts.d.ts +2 -0
  101. package/dist/src/emit-dts.js +82 -0
  102. package/dist/src/emit-js.d.ts +3 -0
  103. package/dist/src/emit-js.js +149 -0
  104. package/dist/src/harness/failures.d.ts +26 -0
  105. package/dist/src/harness/failures.js +113 -0
  106. package/dist/src/harness/idm.d.ts +12 -0
  107. package/dist/src/harness/idm.js +44 -0
  108. package/dist/src/harness/index.d.ts +11 -0
  109. package/dist/src/harness/index.js +6 -0
  110. package/dist/src/harness/lease.d.ts +136 -0
  111. package/dist/src/harness/lease.js +286 -0
  112. package/dist/src/harness/residue.d.ts +23 -0
  113. package/dist/src/harness/residue.js +73 -0
  114. package/dist/src/harness/show-log.d.ts +43 -0
  115. package/dist/src/harness/show-log.js +253 -0
  116. package/dist/src/harness/spec.d.ts +67 -0
  117. package/dist/src/harness/spec.js +189 -0
  118. package/dist/src/harness/step.d.ts +80 -0
  119. package/dist/src/harness/step.js +97 -0
  120. package/dist/src/harness/types.d.ts +100 -0
  121. package/dist/src/harness/types.js +1 -0
  122. package/dist/src/harness/vitest.d.ts +52 -0
  123. package/dist/src/harness/vitest.js +155 -0
  124. package/dist/src/idents.d.ts +4 -0
  125. package/dist/src/idents.js +42 -0
  126. package/dist/src/jvm.d.ts +124 -0
  127. package/dist/src/jvm.js +432 -0
  128. package/dist/src/load.d.ts +2 -0
  129. package/dist/src/load.js +104 -0
  130. package/dist/src/paths.d.ts +36 -0
  131. package/dist/src/paths.js +50 -0
  132. package/dist/src/profile/index.d.ts +5 -0
  133. package/dist/src/profile/index.js +3 -0
  134. package/dist/src/profile/normalise.d.ts +16 -0
  135. package/dist/src/profile/normalise.js +106 -0
  136. package/dist/src/profile/pull.d.ts +22 -0
  137. package/dist/src/profile/pull.js +36 -0
  138. package/dist/src/profile/seed.d.ts +11 -0
  139. package/dist/src/profile/seed.js +28 -0
  140. package/dist/src/profile/store.d.ts +13 -0
  141. package/dist/src/profile/store.js +60 -0
  142. package/dist/src/profile/types.d.ts +45 -0
  143. package/dist/src/profile/types.js +1 -0
  144. package/dist/src/profile/validate.d.ts +38 -0
  145. package/dist/src/profile/validate.js +129 -0
  146. package/dist/src/project.d.ts +37 -0
  147. package/dist/src/project.js +93 -0
  148. package/dist/src/protocol.d.ts +62 -0
  149. package/dist/src/protocol.js +68 -0
  150. package/dist/src/provider-module.d.ts +11 -0
  151. package/dist/src/provider-module.js +46 -0
  152. package/dist/src/runner.d.ts +39 -0
  153. package/dist/src/runner.js +337 -0
  154. package/dist/src/schema.d.ts +48 -0
  155. package/dist/src/schema.js +93 -0
  156. package/generated/scripted-decision-mocks.cjs +722 -0
  157. package/generated/scripted-decision-mocks.d.ts +626 -0
  158. package/generated/scripted-decision-next.json +3640 -0
  159. package/java/AmClassShutter.java +98 -0
  160. package/java/AmContextFactory.java +69 -0
  161. package/java/AmScopes.java +49 -0
  162. package/java/HostOps.java +180 -0
  163. package/java/JsValues.java +39 -0
  164. package/java/Json.java +441 -0
  165. package/java/LanguageVersions.java +55 -0
  166. package/java/Runner.java +289 -0
  167. package/java/ScriptContextScope.java +147 -0
  168. package/package.json +88 -0
  169. package/src/bindings/rhino/runtime.cjs +3021 -0
@@ -0,0 +1,810 @@
1
+ import { deepEqual, matchesPattern } from "./equal.js";
2
+ import { isPortable } from "./portable.js";
3
+ import { ALLOW_UNDECLARED_CHANNELS, DEFAULT_ALLOW_UNDECLARED, LOG_LEVELS, OPENIDM_METHODS, OPENIDM_WRITE_METHODS, STATE_CHANNELS, } from "./types.js";
4
+ import { formatValue, isPlainObject, parseJsonObject, parseJsonValue } from "./util.js";
5
+ import { validateCase } from "./validate.js";
6
+ const EFFECTS_KEYS = [
7
+ "outcome",
8
+ "sharedState",
9
+ "transientState",
10
+ "secureState",
11
+ "callbacks",
12
+ "openidm",
13
+ "http",
14
+ "logs",
15
+ ];
16
+ const WRITE_METHODS = new Set(OPENIDM_WRITE_METHODS);
17
+ const OPENIDM_METHOD_SET = new Set(OPENIDM_METHODS);
18
+ const LOG_LEVEL_SET = new Set(LOG_LEVELS);
19
+ const CHANNEL_SET = new Set(EFFECTS_KEYS);
20
+ const STATE_CHANNEL_SET = new Set(STATE_CHANNELS);
21
+ /**
22
+ * Pure function: (case, recorded effects) → pass/fail with a readable
23
+ * explanation of each difference. Does not run a script.
24
+ */
25
+ export function judge(input, effects) {
26
+ const kase = validateCase(input);
27
+ const recorded = parseEffects(effects);
28
+ const strictness = resolveStrictness(kase.expect.allowUndeclared);
29
+ const evidence = recorded.evidence ?? exactEvidence();
30
+ const unobserved = new Set(evidence.unobservedChannels);
31
+ const reconciled = reconcileUnbucketedState(kase.expect, evidence.unbucketedState, strictness);
32
+ const unverified = [
33
+ ...reconciled.unverified,
34
+ ...qualifyUnifiedState(kase.expect, strictness, evidence),
35
+ ...qualifyUnobserved(kase.expect, strictness, evidence.unobservedChannels),
36
+ ];
37
+ const mismatches = [
38
+ ...reconciled.mismatches,
39
+ ...(unobserved.has("outcome") ? [] : judgeOutcome(kase, recorded)),
40
+ ...(unobserved.has("sharedState") ? [] : judgeState("sharedState", kase.expect.sharedState, recorded.sharedState, strictness.sharedState, reconciled.handled.sharedState)),
41
+ ...(unobserved.has("transientState") ? [] : judgeState("transientState", kase.expect.transientState, recorded.transientState, strictness.transientState, reconciled.handled.transientState)),
42
+ ...(unobserved.has("secureState") ? [] : judgeState("secureState", kase.expect.secureState, recorded.secureState, strictness.secureState, reconciled.handled.secureState)),
43
+ ...(unobserved.has("callbacks")
44
+ ? []
45
+ : judgeCallbacks(kase.expect, recorded, strictness.callbacks)),
46
+ ...(unobserved.has("openidm")
47
+ ? []
48
+ : judgeOpenidm(kase.expect, recorded, strictness)),
49
+ ...(unobserved.has("http")
50
+ ? []
51
+ : judgeHttp(kase.expect, recorded, strictness.http)),
52
+ ...(unobserved.has("logs")
53
+ ? []
54
+ : judgeLogs(kase.expect, recorded, strictness.logs)),
55
+ ];
56
+ return {
57
+ pass: mismatches.length === 0,
58
+ conclusive: unverified.length === 0,
59
+ portable: isPortable(kase),
60
+ mismatches,
61
+ unverified,
62
+ summary: mismatches.length === 0 ? "" : formatSummary(kase, mismatches),
63
+ };
64
+ }
65
+ function reconcileUnbucketedState(expect, mutations, strictness) {
66
+ const mismatches = [];
67
+ const unverified = [];
68
+ const handled = {
69
+ sharedState: new Set(),
70
+ transientState: new Set(),
71
+ secureState: new Set(),
72
+ };
73
+ for (const mutation of mutations) {
74
+ const declarations = stateDeclarations(expect, mutation.key);
75
+ if (declarations.length === 0) {
76
+ const closed = mutation.possibleBuckets.filter((bucket) => !strictness[bucket]);
77
+ if (closed.length > 0) {
78
+ mismatches.push(miss("nodeState", mutation.key, "(none)", formatMutation(mutation), `nodeState: undeclared ${formatMutation(mutation)}; bucket is unobservable (could be ${mutation.possibleBuckets.join(", ")})`));
79
+ }
80
+ continue;
81
+ }
82
+ for (const declaration of declarations) {
83
+ handled[declaration.channel].add(declaration.key);
84
+ }
85
+ if (declarations.length !== 1) {
86
+ mismatches.push(miss("nodeState", mutation.key, declarations.map(formatDeclaration).join(" or "), formatMutation(mutation), `nodeState: ${JSON.stringify(mutation.key)} has multiple bucket expectations, but AIC exposes one unified value`));
87
+ continue;
88
+ }
89
+ const declaration = declarations[0];
90
+ if (declaration === undefined) {
91
+ continue;
92
+ }
93
+ if (!mutation.possibleBuckets.includes(declaration.channel)) {
94
+ mismatches.push(miss(declaration.channel, mutation.key, formatDeclaration(declaration), `${formatMutation(mutation)} in ${mutation.possibleBuckets.join(" or ")}`, `${declaration.channel}: ${JSON.stringify(mutation.key)} cannot be attributed to that bucket`));
95
+ continue;
96
+ }
97
+ if (!mutationMatchesDeclaration(mutation, declaration)) {
98
+ mismatches.push(miss(declaration.channel, mutation.key, formatDeclaration(declaration), formatMutation(mutation), `${declaration.channel}: ${JSON.stringify(mutation.key)} differed in unified nodeState`));
99
+ continue;
100
+ }
101
+ unverified.push({
102
+ channel: declaration.channel,
103
+ path: mutation.key,
104
+ message: `${declaration.channel}: ${JSON.stringify(mutation.key)} ${mutation.operation} value matched, but its bucket is unobservable (could be ${mutation.possibleBuckets.join(", ")})`,
105
+ });
106
+ }
107
+ return { mismatches, unverified, handled };
108
+ }
109
+ function stateDeclarations(expect, key) {
110
+ const declarations = [];
111
+ for (const channel of STATE_CHANNELS) {
112
+ const state = expect[channel];
113
+ if (Object.prototype.hasOwnProperty.call(state?.added ?? {}, key)) {
114
+ const after = state?.added?.[key];
115
+ if (after !== undefined) {
116
+ declarations.push({ channel, key, operation: "added", after });
117
+ }
118
+ }
119
+ if (Object.prototype.hasOwnProperty.call(state?.changed ?? {}, key)) {
120
+ const after = state?.changed?.[key];
121
+ if (after !== undefined) {
122
+ declarations.push({ channel, key, operation: "changed", after });
123
+ }
124
+ }
125
+ if ((state?.removed ?? []).includes(key)) {
126
+ declarations.push({ channel, key, operation: "removed" });
127
+ }
128
+ }
129
+ return declarations;
130
+ }
131
+ function mutationMatchesDeclaration(mutation, declaration) {
132
+ if (mutation.operation !== declaration.operation) {
133
+ return false;
134
+ }
135
+ if (mutation.operation === "removed") {
136
+ return true;
137
+ }
138
+ return deepEqual(mutation.after, declaration.after);
139
+ }
140
+ function formatMutation(mutation) {
141
+ if (mutation.operation === "removed") {
142
+ return `removed ${formatValue(mutation.key)}`;
143
+ }
144
+ if (mutation.operation === "added") {
145
+ return `added ${formatValue(mutation.key)}=${formatValue(mutation.after)}`;
146
+ }
147
+ return `changed ${formatValue(mutation.key)} from ${formatValue(mutation.before)} to ${formatValue(mutation.after)}`;
148
+ }
149
+ function formatDeclaration(declaration) {
150
+ if (declaration.operation === "removed") {
151
+ return `${declaration.channel} removed ${formatValue(declaration.key)}`;
152
+ }
153
+ return `${declaration.channel} ${declaration.operation} ${formatValue(declaration.key)}=${formatValue(declaration.after)}`;
154
+ }
155
+ function qualifyUnobserved(expect, strictness, channels) {
156
+ const gaps = [];
157
+ for (const channel of channels) {
158
+ if (!needsObservation(channel, expect, strictness)) {
159
+ continue;
160
+ }
161
+ gaps.push({
162
+ channel,
163
+ path: channel,
164
+ message: `${channel}: the runner cannot observe this channel, so its expectations or undeclared-effect policy were not verified`,
165
+ });
166
+ }
167
+ return gaps;
168
+ }
169
+ function qualifyUnifiedState(expect, strictness, evidence) {
170
+ if (evidence.stateBuckets === "exact") {
171
+ return [];
172
+ }
173
+ const needsBuckets = STATE_CHANNELS.some((channel) => stateDiffHasEntries(expect[channel])) ||
174
+ STATE_CHANNELS.some((channel) => !strictness[channel]);
175
+ if (!needsBuckets) {
176
+ return [];
177
+ }
178
+ return [
179
+ {
180
+ channel: "nodeState",
181
+ path: "buckets",
182
+ message: "nodeState: the runner observes one unified view; per-bucket absence and hidden lower-precedence writes were not verified",
183
+ },
184
+ ];
185
+ }
186
+ function needsObservation(channel, expect, strictness) {
187
+ if (channel === "outcome") {
188
+ return true;
189
+ }
190
+ if (channel === "sharedState" || channel === "transientState" || channel === "secureState") {
191
+ return stateDiffHasEntries(expect[channel]) || !strictness[channel];
192
+ }
193
+ if (channel === "callbacks") {
194
+ return (expect.callbacks?.length ?? 0) > 0 || !strictness.callbacks;
195
+ }
196
+ if (channel === "openidm") {
197
+ return ((expect.openidm?.length ?? 0) > 0 ||
198
+ !strictness.openidmWrites ||
199
+ !strictness.openidmReads);
200
+ }
201
+ if (channel === "http") {
202
+ return (expect.http?.length ?? 0) > 0 || !strictness.http;
203
+ }
204
+ return (expect.logs?.length ?? 0) > 0 || !strictness.logs;
205
+ }
206
+ function stateDiffHasEntries(diff) {
207
+ return (Object.keys(diff?.added ?? {}).length > 0 ||
208
+ Object.keys(diff?.changed ?? {}).length > 0 ||
209
+ (diff?.removed?.length ?? 0) > 0);
210
+ }
211
+ function exactEvidence() {
212
+ return {
213
+ stateBuckets: "exact",
214
+ ambientState: {},
215
+ unbucketedState: [],
216
+ unobservedChannels: [],
217
+ };
218
+ }
219
+ function resolveStrictness(flags) {
220
+ const resolved = { ...DEFAULT_ALLOW_UNDECLARED };
221
+ if (flags === undefined) {
222
+ return resolved;
223
+ }
224
+ for (const key of ALLOW_UNDECLARED_CHANNELS) {
225
+ const value = flags[key];
226
+ if (value !== undefined) {
227
+ resolved[key] = value;
228
+ }
229
+ }
230
+ return resolved;
231
+ }
232
+ function judgeOutcome(kase, effects) {
233
+ if (effects.outcome === kase.expect.outcome) {
234
+ return [];
235
+ }
236
+ const actual = effects.outcome === null ? "<no outcome>" : formatValue(effects.outcome);
237
+ const wanted = kase.expect.outcome === null
238
+ ? "<no outcome>"
239
+ : formatValue(kase.expect.outcome);
240
+ // Expecting no outcome is expecting a suspend, so say that rather than
241
+ // "expected <no outcome>, actual \"true\"" — the script did not fail to
242
+ // produce something, it failed to stop and ask.
243
+ if (kase.expect.outcome === null) {
244
+ return [
245
+ miss("outcome", "outcome", wanted, actual, `outcome: expected the pass to suspend with callbacks and decide nothing, but it reached ${actual}`),
246
+ ];
247
+ }
248
+ // An outcome outside the declared vocabulary is a harness configuration
249
+ // fault, not the script behaving differently from expectation, and it
250
+ // supersedes the ordinary mismatch because the ordinary message ("expected
251
+ // X, actual Y") invites you to go and look at Y's branch — when the real
252
+ // problem is that Y is not wired into the journey at all. On a tenant this
253
+ // is the difference between a readable failure and a bare 401.
254
+ if (kase.outcomes !== undefined &&
255
+ effects.outcome !== null &&
256
+ !kase.outcomes.includes(effects.outcome)) {
257
+ const declared = kase.outcomes.map((name) => formatValue(name)).join(", ");
258
+ return [
259
+ miss("outcome", "outcome", wanted, actual, `outcome: script produced ${actual}, which case.outcomes does not declare [${declared}] — fix the typo or declare it; on a tenant an undeclared outcome answers 401 with no callback, identical to a compile error`),
260
+ ];
261
+ }
262
+ const message = effects.outcome === null
263
+ ? `outcome: expected ${wanted}, script produced no outcome`
264
+ : `outcome: expected ${wanted}, actual ${actual}`;
265
+ return [miss("outcome", "outcome", wanted, actual, message)];
266
+ }
267
+ function diffState(initial, final) {
268
+ const added = {};
269
+ const changed = {};
270
+ const removed = [];
271
+ const initialKeys = new Set(Object.keys(initial));
272
+ const finalKeys = new Set(Object.keys(final));
273
+ for (const key of finalKeys) {
274
+ if (!initialKeys.has(key)) {
275
+ const value = final[key];
276
+ if (value !== undefined) {
277
+ added[key] = value;
278
+ }
279
+ continue;
280
+ }
281
+ if (!deepEqual(initial[key], final[key])) {
282
+ const value = final[key];
283
+ if (value !== undefined) {
284
+ changed[key] = value;
285
+ }
286
+ }
287
+ }
288
+ for (const key of initialKeys) {
289
+ if (!finalKeys.has(key)) {
290
+ removed.push(key);
291
+ }
292
+ }
293
+ removed.sort();
294
+ return { added, changed, removed };
295
+ }
296
+ function judgeState(channel, expected, bucket, allowUndeclared, handled = new Set()) {
297
+ const actual = diffState(bucket.initial, bucket.final);
298
+ const expectedAdded = expected?.added ?? {};
299
+ const expectedChanged = expected?.changed ?? {};
300
+ const expectedRemoved = expected?.removed ?? [];
301
+ const expectedRemovedSet = new Set(expectedRemoved);
302
+ const mismatches = [];
303
+ for (const key of Object.keys(expectedAdded)) {
304
+ if (handled.has(key)) {
305
+ continue;
306
+ }
307
+ const want = expectedAdded[key];
308
+ if (Object.prototype.hasOwnProperty.call(actual.added, key)) {
309
+ if (!deepEqual(want, actual.added[key])) {
310
+ mismatches.push(miss(channel, key, formatValue(want), formatValue(actual.added[key]), `${channel}: added ${formatValue(key)} differed`));
311
+ }
312
+ continue;
313
+ }
314
+ if (Object.prototype.hasOwnProperty.call(actual.changed, key)) {
315
+ mismatches.push(miss(channel, key, `added ${formatValue(want)}`, `changed to ${formatValue(actual.changed[key])}`, `${channel}: ${formatValue(key)} was changed, not added (already present in initial state)`));
316
+ continue;
317
+ }
318
+ if (actual.removed.includes(key)) {
319
+ mismatches.push(miss(channel, key, `added ${formatValue(want)}`, "removed", `${channel}: ${formatValue(key)} was removed, not added`));
320
+ continue;
321
+ }
322
+ if (Object.prototype.hasOwnProperty.call(bucket.final, key)) {
323
+ mismatches.push(miss(channel, key, `added ${formatValue(want)}`, `unchanged ${formatValue(bucket.final[key])}`, `${channel}: ${formatValue(key)} was unchanged, not added`));
324
+ continue;
325
+ }
326
+ mismatches.push(miss(channel, key, formatValue(want), "<absent>", `${channel}: missing added ${formatValue(key)}`));
327
+ }
328
+ for (const key of Object.keys(expectedChanged)) {
329
+ if (handled.has(key)) {
330
+ continue;
331
+ }
332
+ const want = expectedChanged[key];
333
+ if (Object.prototype.hasOwnProperty.call(actual.changed, key)) {
334
+ if (!deepEqual(want, actual.changed[key])) {
335
+ mismatches.push(miss(channel, key, formatValue(want), formatValue(actual.changed[key]), `${channel}: changed ${formatValue(key)} differed`));
336
+ }
337
+ continue;
338
+ }
339
+ if (Object.prototype.hasOwnProperty.call(actual.added, key)) {
340
+ mismatches.push(miss(channel, key, `changed to ${formatValue(want)}`, `added ${formatValue(actual.added[key])}`, `${channel}: ${formatValue(key)} was added, not changed (absent from initial state)`));
341
+ continue;
342
+ }
343
+ if (actual.removed.includes(key)) {
344
+ mismatches.push(miss(channel, key, `changed to ${formatValue(want)}`, "removed", `${channel}: ${formatValue(key)} was removed, not changed`));
345
+ continue;
346
+ }
347
+ if (Object.prototype.hasOwnProperty.call(bucket.final, key)) {
348
+ mismatches.push(miss(channel, key, `changed to ${formatValue(want)}`, `unchanged ${formatValue(bucket.final[key])}`, `${channel}: ${formatValue(key)} did not change`));
349
+ continue;
350
+ }
351
+ mismatches.push(miss(channel, key, formatValue(want), "<absent>", `${channel}: missing changed ${formatValue(key)}`));
352
+ }
353
+ for (const key of expectedRemoved) {
354
+ if (handled.has(key)) {
355
+ continue;
356
+ }
357
+ if (actual.removed.includes(key)) {
358
+ continue;
359
+ }
360
+ if (Object.prototype.hasOwnProperty.call(bucket.final, key)) {
361
+ mismatches.push(miss(channel, key, "removed", formatValue(bucket.final[key]), `${channel}: ${formatValue(key)} was not removed`));
362
+ continue;
363
+ }
364
+ mismatches.push(miss(channel, key, "removed", "<absent from initial state>", `${channel}: ${formatValue(key)} was absent, not removed`));
365
+ }
366
+ if (allowUndeclared) {
367
+ return mismatches;
368
+ }
369
+ const mentioned = (key) => Object.prototype.hasOwnProperty.call(expectedAdded, key) ||
370
+ Object.prototype.hasOwnProperty.call(expectedChanged, key) ||
371
+ expectedRemovedSet.has(key);
372
+ for (const key of Object.keys(actual.added)) {
373
+ if (mentioned(key)) {
374
+ continue;
375
+ }
376
+ mismatches.push(miss(channel, key, "(none)", formatValue(actual.added[key]), `${channel}: undeclared added ${formatValue(key)}`));
377
+ }
378
+ for (const key of Object.keys(actual.changed)) {
379
+ if (mentioned(key)) {
380
+ continue;
381
+ }
382
+ mismatches.push(miss(channel, key, "(none)", formatValue(actual.changed[key]), `${channel}: undeclared changed ${formatValue(key)}`));
383
+ }
384
+ for (const key of actual.removed) {
385
+ if (mentioned(key)) {
386
+ continue;
387
+ }
388
+ mismatches.push(miss(channel, key, "(none)", "removed", `${channel}: undeclared removed ${formatValue(key)}`));
389
+ }
390
+ return mismatches;
391
+ }
392
+ function judgeCallbacks(expect, effects, allowUndeclared) {
393
+ const expected = expect.callbacks ?? [];
394
+ const actual = effects.callbacks;
395
+ if (allowUndeclared) {
396
+ if (isSubsequence(expected, actual)) {
397
+ return [];
398
+ }
399
+ return [
400
+ miss("callbacks", "callbacks", formatValue(expected), formatValue(actual), "callbacks: expected callbacks did not appear in order"),
401
+ ];
402
+ }
403
+ const mismatches = [];
404
+ const length = Math.max(expected.length, actual.length);
405
+ for (let index = 0; index < length; index += 1) {
406
+ const want = expected[index];
407
+ const got = actual[index];
408
+ if (deepEqual(want, got)) {
409
+ continue;
410
+ }
411
+ mismatches.push(miss("callbacks", `[${index}]`, want === undefined ? "(none)" : formatValue(want), got === undefined ? "(none)" : formatValue(got), `callbacks: [${index}] differed`));
412
+ }
413
+ return mismatches;
414
+ }
415
+ function isSubsequence(expected, actual) {
416
+ let index = 0;
417
+ for (const item of actual) {
418
+ const want = expected[index];
419
+ if (want !== undefined && deepEqual(want, item)) {
420
+ index += 1;
421
+ }
422
+ }
423
+ return index === expected.length;
424
+ }
425
+ function judgeOpenidm(expect, effects, strictness) {
426
+ const expected = expect.openidm ?? [];
427
+ const mismatches = [];
428
+ const declared = new Set();
429
+ for (const item of expected) {
430
+ const matched = matchIndices(item, effects.openidm, openidmMatches);
431
+ const times = item.times !== undefined ? item.times : 1;
432
+ for (const index of matched) {
433
+ declared.add(index);
434
+ }
435
+ if (matched.length !== times) {
436
+ mismatches.push(miss("openidm", formatOpenidmExpect(item), `${times}`, `${matched.length}`, `openidm: expected ${times} ${formatOpenidmExpect(item)}, actual ${matched.length}`));
437
+ }
438
+ }
439
+ for (let index = 0; index < effects.openidm.length; index += 1) {
440
+ if (declared.has(index)) {
441
+ continue;
442
+ }
443
+ const actual = effects.openidm[index];
444
+ if (actual === undefined) {
445
+ continue;
446
+ }
447
+ const write = WRITE_METHODS.has(actual.method);
448
+ if (write && strictness.openidmWrites) {
449
+ continue;
450
+ }
451
+ if (!write && strictness.openidmReads) {
452
+ continue;
453
+ }
454
+ const kind = write ? "write" : "read";
455
+ mismatches.push(miss("openidm", formatOpenidm(actual), "(none)", formatOpenidm(actual), `openidm: undeclared ${kind} ${formatOpenidm(actual)}`));
456
+ }
457
+ return mismatches;
458
+ }
459
+ function judgeHttp(expect, effects, allowUndeclared) {
460
+ const expected = expect.http ?? [];
461
+ const mismatches = [];
462
+ const declared = new Set();
463
+ for (const item of expected) {
464
+ const matched = matchIndices(item, effects.http, httpMatches);
465
+ const times = item.times !== undefined ? item.times : 1;
466
+ for (const index of matched) {
467
+ declared.add(index);
468
+ }
469
+ if (matched.length !== times) {
470
+ mismatches.push(miss("http", formatHttpExpect(item), `${times}`, `${matched.length}`, `http: expected ${times} request matching ${formatHttpExpect(item)}, actual ${matched.length}`));
471
+ }
472
+ }
473
+ if (allowUndeclared) {
474
+ return mismatches;
475
+ }
476
+ for (let index = 0; index < effects.http.length; index += 1) {
477
+ if (declared.has(index)) {
478
+ continue;
479
+ }
480
+ const actual = effects.http[index];
481
+ if (actual === undefined) {
482
+ continue;
483
+ }
484
+ mismatches.push(miss("http", formatHttp(actual), "(none)", formatHttp(actual), `http: undeclared request ${formatHttp(actual)}`));
485
+ }
486
+ return mismatches;
487
+ }
488
+ function judgeLogs(expect, effects, allowUndeclared) {
489
+ const expected = expect.logs ?? [];
490
+ const mismatches = [];
491
+ const declared = new Set();
492
+ for (const item of expected) {
493
+ const matched = matchIndices(item, effects.logs, logMatches);
494
+ const times = item.times !== undefined ? item.times : 1;
495
+ for (const index of matched) {
496
+ declared.add(index);
497
+ }
498
+ if (matched.length !== times) {
499
+ mismatches.push(miss("logs", formatLogExpect(item), `${times}`, `${matched.length}`, `logs: expected ${times} line matching ${formatLogExpect(item)}, actual ${matched.length}`));
500
+ }
501
+ }
502
+ if (allowUndeclared) {
503
+ return mismatches;
504
+ }
505
+ for (let index = 0; index < effects.logs.length; index += 1) {
506
+ if (declared.has(index)) {
507
+ continue;
508
+ }
509
+ const actual = effects.logs[index];
510
+ if (actual === undefined) {
511
+ continue;
512
+ }
513
+ mismatches.push(miss("logs", formatLog(actual), "(none)", formatLog(actual), `logs: undeclared line ${formatLog(actual)}`));
514
+ }
515
+ return mismatches;
516
+ }
517
+ function matchIndices(expected, actuals, matches) {
518
+ const indices = [];
519
+ for (let index = 0; index < actuals.length; index += 1) {
520
+ const actual = actuals[index];
521
+ if (actual !== undefined && matches(expected, actual)) {
522
+ indices.push(index);
523
+ }
524
+ }
525
+ return indices;
526
+ }
527
+ function openidmMatches(expected, actual) {
528
+ if (actual.method !== expected.method) {
529
+ return false;
530
+ }
531
+ if (!matchesPattern(expected.resource, actual.resource)) {
532
+ return false;
533
+ }
534
+ if (expected.body !== undefined && !deepEqual(expected.body, actual.body)) {
535
+ return false;
536
+ }
537
+ if (expected.actionName !== undefined) {
538
+ if (actual.actionName === undefined) {
539
+ return false;
540
+ }
541
+ if (!matchesPattern(expected.actionName, actual.actionName)) {
542
+ return false;
543
+ }
544
+ }
545
+ return true;
546
+ }
547
+ function httpMatches(expected, actual) {
548
+ if (!matchesPattern(expected.url, actual.url)) {
549
+ return false;
550
+ }
551
+ if (expected.method !== undefined && actual.method !== expected.method) {
552
+ return false;
553
+ }
554
+ return true;
555
+ }
556
+ function logMatches(expected, actual) {
557
+ if (expected.level !== undefined && actual.level !== expected.level) {
558
+ return false;
559
+ }
560
+ return matchesPattern(expected.message, actual.message);
561
+ }
562
+ function parseEffects(raw) {
563
+ if (!isPlainObject(raw)) {
564
+ throw new Error("rhino-local: effects is not an object");
565
+ }
566
+ for (const key of EFFECTS_KEYS) {
567
+ if (!(key in raw)) {
568
+ throw new Error(`rhino-local: effects is missing ${key} — a runner that does not record a channel would silently assert nothing`);
569
+ }
570
+ }
571
+ if (raw.outcome !== null && typeof raw.outcome !== "string") {
572
+ throw new Error("rhino-local: effects.outcome must be a string or null");
573
+ }
574
+ const recorded = {
575
+ outcome: raw.outcome,
576
+ sharedState: parseStateBucket(raw.sharedState, "effects.sharedState"),
577
+ transientState: parseStateBucket(raw.transientState, "effects.transientState"),
578
+ secureState: parseStateBucket(raw.secureState, "effects.secureState"),
579
+ callbacks: parseArray(raw.callbacks, "effects.callbacks", parseCallbackEffect),
580
+ openidm: parseArray(raw.openidm, "effects.openidm", parseOpenidmEffect),
581
+ http: parseArray(raw.http, "effects.http", parseHttpEffect),
582
+ logs: parseArray(raw.logs, "effects.logs", parseLogEffect),
583
+ };
584
+ if (raw.evidence !== undefined) {
585
+ recorded.evidence = parseRecordingEvidence(raw.evidence);
586
+ for (const channel of recorded.evidence.unobservedChannels) {
587
+ if (recordedChannelHasValues(recorded, channel)) {
588
+ throw new Error(`rhino-local: effects.${channel} cannot contain values while evidence marks it unobserved`);
589
+ }
590
+ }
591
+ }
592
+ return recorded;
593
+ }
594
+ function parseRecordingEvidence(raw) {
595
+ if (!isPlainObject(raw)) {
596
+ throw new Error("rhino-local: effects.evidence is not an object");
597
+ }
598
+ for (const key of [
599
+ "stateBuckets",
600
+ "ambientState",
601
+ "unbucketedState",
602
+ "unobservedChannels",
603
+ ]) {
604
+ if (!(key in raw)) {
605
+ throw new Error(`rhino-local: effects.evidence is missing ${key}`);
606
+ }
607
+ }
608
+ const unobservedChannels = parseStringArray(raw.unobservedChannels, "effects.evidence.unobservedChannels", CHANNEL_SET);
609
+ if (raw.stateBuckets !== "exact" && raw.stateBuckets !== "unified") {
610
+ throw new Error('rhino-local: effects.evidence.stateBuckets must be "exact" or "unified"');
611
+ }
612
+ return {
613
+ stateBuckets: raw.stateBuckets,
614
+ ambientState: parseJsonObject(raw.ambientState, "effects.evidence.ambientState"),
615
+ unbucketedState: parseArray(raw.unbucketedState, "effects.evidence.unbucketedState", parseUnbucketedMutation),
616
+ unobservedChannels,
617
+ };
618
+ }
619
+ function parseUnbucketedMutation(raw, path) {
620
+ if (!isPlainObject(raw)) {
621
+ throw new Error(`rhino-local: ${path} is not an object`);
622
+ }
623
+ if (typeof raw.operation !== "string") {
624
+ throw new Error(`rhino-local: ${path}.operation must be a string`);
625
+ }
626
+ if (typeof raw.key !== "string") {
627
+ throw new Error(`rhino-local: ${path}.key must be a string`);
628
+ }
629
+ const possibleBuckets = parseStringArray(raw.possibleBuckets, `${path}.possibleBuckets`, STATE_CHANNEL_SET);
630
+ if (possibleBuckets.length === 0) {
631
+ throw new Error(`rhino-local: ${path}.possibleBuckets must not be empty`);
632
+ }
633
+ if (raw.operation === "added") {
634
+ return {
635
+ operation: "added",
636
+ key: raw.key,
637
+ after: parseJsonValue(raw.after, `${path}.after`),
638
+ possibleBuckets,
639
+ };
640
+ }
641
+ if (raw.operation === "changed") {
642
+ return {
643
+ operation: "changed",
644
+ key: raw.key,
645
+ before: parseJsonValue(raw.before, `${path}.before`),
646
+ after: parseJsonValue(raw.after, `${path}.after`),
647
+ possibleBuckets,
648
+ };
649
+ }
650
+ if (raw.operation === "removed") {
651
+ return {
652
+ operation: "removed",
653
+ key: raw.key,
654
+ before: parseJsonValue(raw.before, `${path}.before`),
655
+ possibleBuckets,
656
+ };
657
+ }
658
+ throw new Error(`rhino-local: ${path}.operation must be added, changed, or removed`);
659
+ }
660
+ function parseStringArray(raw, path, allowed) {
661
+ if (!Array.isArray(raw) || raw.some((value) => typeof value !== "string")) {
662
+ throw new Error(`rhino-local: ${path} must be an array of strings`);
663
+ }
664
+ const values = raw;
665
+ for (const value of values) {
666
+ if (!allowed.has(value)) {
667
+ throw new Error(`rhino-local: ${path} contains unknown value ${formatValue(value)}`);
668
+ }
669
+ }
670
+ return [...new Set(values)];
671
+ }
672
+ function recordedChannelHasValues(effects, channel) {
673
+ if (channel === "outcome") {
674
+ return effects.outcome !== null;
675
+ }
676
+ if (channel === "sharedState" || channel === "transientState" || channel === "secureState") {
677
+ return !deepEqual(effects[channel].initial, effects[channel].final);
678
+ }
679
+ return effects[channel].length > 0;
680
+ }
681
+ function parseStateBucket(raw, path) {
682
+ if (!isPlainObject(raw)) {
683
+ throw new Error(`rhino-local: ${path} is not an object`);
684
+ }
685
+ if (!("initial" in raw) || !("final" in raw)) {
686
+ throw new Error(`rhino-local: ${path} must have initial and final objects`);
687
+ }
688
+ return {
689
+ initial: parseJsonObject(raw.initial, `${path}.initial`),
690
+ final: parseJsonObject(raw.final, `${path}.final`),
691
+ };
692
+ }
693
+ function parseCallbackEffect(raw, path) {
694
+ if (!isPlainObject(raw)) {
695
+ throw new Error(`rhino-local: ${path} is not an object`);
696
+ }
697
+ if (typeof raw.type !== "string" || raw.type.trim() === "") {
698
+ throw new Error(`rhino-local: ${path}.type must be a non-empty string`);
699
+ }
700
+ const callback = { type: raw.type };
701
+ for (const [key, value] of Object.entries(raw)) {
702
+ if (key === "type") {
703
+ continue;
704
+ }
705
+ callback[key] = parseJsonValue(value, `${path}.${key}`);
706
+ }
707
+ return callback;
708
+ }
709
+ function parseOpenidmEffect(raw, path) {
710
+ if (!isPlainObject(raw)) {
711
+ throw new Error(`rhino-local: ${path} is not an object`);
712
+ }
713
+ if (typeof raw.method !== "string" || !OPENIDM_METHOD_SET.has(raw.method)) {
714
+ throw new Error(`rhino-local: ${path}.method must be one of ${OPENIDM_METHODS.join(", ")}`);
715
+ }
716
+ if (typeof raw.resource !== "string") {
717
+ throw new Error(`rhino-local: ${path}.resource must be a string`);
718
+ }
719
+ const effect = {
720
+ method: raw.method,
721
+ resource: raw.resource,
722
+ };
723
+ if (raw.body !== undefined) {
724
+ effect.body = parseJsonValue(raw.body, `${path}.body`);
725
+ }
726
+ if (raw.actionName !== undefined) {
727
+ if (typeof raw.actionName !== "string") {
728
+ throw new Error(`rhino-local: ${path}.actionName must be a string`);
729
+ }
730
+ effect.actionName = raw.actionName;
731
+ }
732
+ return effect;
733
+ }
734
+ function parseHttpEffect(raw, path) {
735
+ if (!isPlainObject(raw)) {
736
+ throw new Error(`rhino-local: ${path} is not an object`);
737
+ }
738
+ if (typeof raw.url !== "string") {
739
+ throw new Error(`rhino-local: ${path}.url must be a string`);
740
+ }
741
+ if (typeof raw.method !== "string" || raw.method.trim() === "") {
742
+ throw new Error(`rhino-local: ${path}.method must be a non-empty string`);
743
+ }
744
+ const effect = { url: raw.url, method: raw.method };
745
+ if (raw.body !== undefined) {
746
+ effect.body = parseJsonValue(raw.body, `${path}.body`);
747
+ }
748
+ return effect;
749
+ }
750
+ function parseLogEffect(raw, path) {
751
+ if (!isPlainObject(raw)) {
752
+ throw new Error(`rhino-local: ${path} is not an object`);
753
+ }
754
+ if (typeof raw.level !== "string" || !LOG_LEVEL_SET.has(raw.level)) {
755
+ throw new Error(`rhino-local: ${path}.level must be one of ${LOG_LEVELS.join(", ")}`);
756
+ }
757
+ if (typeof raw.message !== "string") {
758
+ throw new Error(`rhino-local: ${path}.message must be a string`);
759
+ }
760
+ return { level: raw.level, message: raw.message };
761
+ }
762
+ function parseArray(raw, path, item) {
763
+ if (!Array.isArray(raw)) {
764
+ throw new Error(`rhino-local: ${path} is not an array`);
765
+ }
766
+ return raw.map((value, index) => item(value, `${path}[${index}]`));
767
+ }
768
+ function formatSummary(kase, mismatches) {
769
+ const plural = mismatches.length === 1 ? "" : "es";
770
+ const header = `${JSON.stringify(kase.name)} failed (${mismatches.length} mismatch${plural}):`;
771
+ const body = mismatches
772
+ .map((item) => `${item.message}\n expected: ${item.expected}\n actual: ${item.actual}`)
773
+ .join("\n");
774
+ return `${header}\n${body}`;
775
+ }
776
+ function formatOpenidm(op) {
777
+ const action = op.actionName === undefined ? "" : ` action=${formatValue(op.actionName)}`;
778
+ const body = op.body === undefined ? "" : ` body=${formatValue(op.body)}`;
779
+ return `${op.method} ${op.resource}${action}${body}`;
780
+ }
781
+ function formatOpenidmExpect(item) {
782
+ const resource = typeof item.resource === "string"
783
+ ? item.resource
784
+ : String(item.resource);
785
+ const action = item.actionName === undefined
786
+ ? ""
787
+ : ` action=${typeof item.actionName === "string" ? formatValue(item.actionName) : String(item.actionName)}`;
788
+ return `${item.method} ${resource}${action}`;
789
+ }
790
+ function formatHttp(op) {
791
+ return `${op.method} ${op.url}`;
792
+ }
793
+ function formatHttpExpect(item) {
794
+ const url = typeof item.url === "string" ? item.url : String(item.url);
795
+ const method = item.method === undefined ? "" : `${item.method} `;
796
+ return `${method}${url}`;
797
+ }
798
+ function formatLog(op) {
799
+ return `${op.level} ${formatValue(op.message)}`;
800
+ }
801
+ function formatLogExpect(item) {
802
+ const level = item.level === undefined ? "" : `${item.level} `;
803
+ const message = typeof item.message === "string"
804
+ ? formatValue(item.message)
805
+ : String(item.message);
806
+ return `${level}${message}`;
807
+ }
808
+ function miss(channel, path, expected, actual, message) {
809
+ return { channel, path, expected, actual, message };
810
+ }