@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,67 @@
1
+ import type { z } from "zod";
2
+ import type { Case, Expect, Given, JsonObject } from "../case/types.ts";
3
+ import type { Channels, RequestDraft, SuiteSpec, WireMap } from "./types.ts";
4
+ /** Shared-state key prefix the tenant's config library reads before the ESV. */
5
+ export declare const ESV_STATE_PREFIX = "esv.";
6
+ /**
7
+ * Collapse the suite's `always` and one test's overrides into a single draft.
8
+ *
9
+ * Merging is per key, not per channel: a test that sets one header keeps the
10
+ * suite's others. Replacing the whole channel would make `always` useless the
11
+ * moment a test needed to add anything, which is the failure mode that drives
12
+ * people to copy the defaults into every test and then let them drift.
13
+ */
14
+ export declare function mergeChannels(always: Channels | undefined, override: Channels | undefined): RequestDraft;
15
+ /**
16
+ * A header or parameter is `string | string[]`; the wire form is always a
17
+ * list, because AM reports one list element per occurrence in send order
18
+ * (verified 2026-09-08 on both engines). A bare string is therefore a
19
+ * one-element list, not a separate case.
20
+ */
21
+ export declare function normaliseWire(map: WireMap | undefined): Record<string, string[]>;
22
+ /**
23
+ * Fold the parsed inputs and the ESV overrides into shared state.
24
+ *
25
+ * Inputs land under their own names and ESVs under `esv.<name>`, so a suite
26
+ * declaring an input called `esv.x` would be ambiguous; that is rejected
27
+ * rather than resolved by precedence, because either precedence is a
28
+ * defensible guess and neither is visible at the call site.
29
+ */
30
+ export declare function applyInputsAndEsv(draft: RequestDraft, input: Readonly<Record<string, unknown>>): void;
31
+ /** Parse a run's inputs against the suite's schema, or reject extras. */
32
+ export declare function parseInputs<TSchema extends z.ZodType>(spec: Pick<SuiteSpec<TSchema>, "name" | "inputs">, raw: unknown): Record<string, unknown>;
33
+ /**
34
+ * Turn a resolved draft into a `Given`.
35
+ *
36
+ * `session` compiles to `given.existingSession`, whose shape was measured
37
+ * 2026-09-14 on both evaluators: a String->String map, present only when the
38
+ * request carries a session cookie. Values are coerced here rather than in the
39
+ * mock, so the AIC lane — which has to put them through a mini journey's
40
+ * `putSessionProperty` — sends exactly what the local lane seeded.
41
+ */
42
+ export declare function toGiven(draft: RequestDraft, base?: Given, realm?: string): Given;
43
+ /** Assemble the `Case` both lanes are judged against. One definition. */
44
+ export declare function toCase<TSchema extends z.ZodType>(spec: Pick<SuiteSpec<TSchema>, "name" | "script" | "outcomes">,
45
+ /**
46
+ * Used verbatim. The vitest adapter supplies an already-qualified
47
+ * "describe > test" path, so prefixing the suite name here would print it
48
+ * twice in every failure message.
49
+ */
50
+ caseName: string, draft: RequestDraft, expect: Expect, base?: Given): Case;
51
+ /**
52
+ * The same `Case`, from a `Given` that is already resolved.
53
+ *
54
+ * A step chain's later passes cannot go through `toCase`: `toGiven` layers the
55
+ * draft over the base, so the request's original seeds would win over what the
56
+ * previous pass actually left in state — the chain would silently restart from
57
+ * the top on every step.
58
+ */
59
+ export declare function caseWithGiven<TSchema extends z.ZodType>(spec: Pick<SuiteSpec<TSchema>, "name" | "script" | "outcomes">, caseName: string, given: Given, expect: Expect): Case;
60
+ export type { JsonObject };
61
+ /**
62
+ * Who the mini journey logs in as. The principal need not exist as a managed
63
+ * object (measured 2026-09-14), so this is free; taking it from shared state
64
+ * means a suite that already sets `username` gets a session for that user
65
+ * without saying so twice.
66
+ */
67
+ export declare function sessionPrincipal(draft: RequestDraft): string;
@@ -0,0 +1,189 @@
1
+ import { AM_OWNED_SESSION_SET, DEFAULT_SESSION_PRINCIPAL, sessionFromPrincipal, } from "../case/session.js";
2
+ /** Shared-state key prefix the tenant's config library reads before the ESV. */
3
+ export const ESV_STATE_PREFIX = "esv.";
4
+ /**
5
+ * Collapse the suite's `always` and one test's overrides into a single draft.
6
+ *
7
+ * Merging is per key, not per channel: a test that sets one header keeps the
8
+ * suite's others. Replacing the whole channel would make `always` useless the
9
+ * moment a test needed to add anything, which is the failure mode that drives
10
+ * people to copy the defaults into every test and then let them drift.
11
+ */
12
+ export function mergeChannels(always, override) {
13
+ return {
14
+ state: {
15
+ shared: { ...(always?.state?.shared ?? {}), ...(override?.state?.shared ?? {}) },
16
+ transient: {
17
+ ...(always?.state?.transient ?? {}),
18
+ ...(override?.state?.transient ?? {}),
19
+ },
20
+ },
21
+ esv: { ...(always?.esv ?? {}), ...(override?.esv ?? {}) },
22
+ headers: { ...normaliseWire(always?.headers), ...normaliseWire(override?.headers) },
23
+ params: { ...normaliseWire(always?.params), ...normaliseWire(override?.params) },
24
+ session: { ...(always?.session ?? {}), ...(override?.session ?? {}) },
25
+ // Declared-empty and not-declared are different requests: `session: {}`
26
+ // asks for a logged-in session with no extra properties, which is a real
27
+ // case and is invisible if you only look at the merged key count.
28
+ sessionRequested: always?.session !== undefined || override?.session !== undefined,
29
+ };
30
+ }
31
+ /**
32
+ * A header or parameter is `string | string[]`; the wire form is always a
33
+ * list, because AM reports one list element per occurrence in send order
34
+ * (verified 2026-09-08 on both engines). A bare string is therefore a
35
+ * one-element list, not a separate case.
36
+ */
37
+ export function normaliseWire(map) {
38
+ const out = {};
39
+ for (const [key, value] of Object.entries(map ?? {})) {
40
+ out[key] = wireValues(key, value);
41
+ }
42
+ return out;
43
+ }
44
+ function wireValues(key, value) {
45
+ if (typeof value === "string") {
46
+ return [value];
47
+ }
48
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
49
+ throw new Error(`rhino-local: ${key} must be a string or an array of strings`);
50
+ }
51
+ if (value.length === 0) {
52
+ throw new Error(`rhino-local: ${key} is an empty array — a key present with no values cannot be sent; omit the key instead`);
53
+ }
54
+ return [...value];
55
+ }
56
+ /**
57
+ * Fold the parsed inputs and the ESV overrides into shared state.
58
+ *
59
+ * Inputs land under their own names and ESVs under `esv.<name>`, so a suite
60
+ * declaring an input called `esv.x` would be ambiguous; that is rejected
61
+ * rather than resolved by precedence, because either precedence is a
62
+ * defensible guess and neither is visible at the call site.
63
+ */
64
+ export function applyInputsAndEsv(draft, input) {
65
+ for (const [key, value] of Object.entries(input)) {
66
+ if (key.startsWith(ESV_STATE_PREFIX)) {
67
+ throw new Error(`rhino-local: input ${JSON.stringify(key)} collides with the ${JSON.stringify(ESV_STATE_PREFIX)} namespace reserved for ESV overrides; rename the input`);
68
+ }
69
+ draft.state.shared[key] = value;
70
+ }
71
+ for (const [name, value] of Object.entries(draft.esv)) {
72
+ draft.state.shared[`${ESV_STATE_PREFIX}${name}`] = value;
73
+ }
74
+ }
75
+ /** Parse a run's inputs against the suite's schema, or reject extras. */
76
+ export function parseInputs(spec, raw) {
77
+ if (spec.inputs === undefined) {
78
+ if (raw !== undefined && Object.keys(raw).length > 0) {
79
+ throw new Error(`rhino-local: ${spec.name} passed inputs but declares none; add an \`inputs\` schema to the suite`);
80
+ }
81
+ return {};
82
+ }
83
+ const result = spec.inputs.safeParse(raw ?? {});
84
+ if (!result.success) {
85
+ const detail = result.error.issues
86
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
87
+ .join("; ");
88
+ throw new Error(`rhino-local: ${spec.name} input invalid — ${detail}`);
89
+ }
90
+ return result.data;
91
+ }
92
+ /**
93
+ * Turn a resolved draft into a `Given`.
94
+ *
95
+ * `session` compiles to `given.existingSession`, whose shape was measured
96
+ * 2026-09-14 on both evaluators: a String->String map, present only when the
97
+ * request carries a session cookie. Values are coerced here rather than in the
98
+ * mock, so the AIC lane — which has to put them through a mini journey's
99
+ * `putSessionProperty` — sends exactly what the local lane seeded.
100
+ */
101
+ export function toGiven(draft, base = {}, realm = "alpha") {
102
+ const given = { ...base };
103
+ if (draft.sessionRequested) {
104
+ given.existingSession = {
105
+ ...sessionFromPrincipal(sessionPrincipal(draft), realm),
106
+ ...(base.existingSession ?? {}),
107
+ ...sessionStrings(draft.session),
108
+ };
109
+ }
110
+ if (Object.keys(draft.state.shared).length > 0) {
111
+ given.sharedState = { ...(base.sharedState ?? {}), ...draft.state.shared };
112
+ }
113
+ if (Object.keys(draft.state.transient).length > 0) {
114
+ given.transientState = {
115
+ ...(base.transientState ?? {}),
116
+ ...draft.state.transient,
117
+ };
118
+ }
119
+ if (Object.keys(draft.headers).length > 0) {
120
+ given.requestHeaders = { ...(base.requestHeaders ?? {}), ...draft.headers };
121
+ }
122
+ if (Object.keys(draft.params).length > 0) {
123
+ given.requestParameters = {
124
+ ...(base.requestParameters ?? {}),
125
+ ...draft.params,
126
+ };
127
+ }
128
+ return given;
129
+ }
130
+ /** Assemble the `Case` both lanes are judged against. One definition. */
131
+ export function toCase(spec,
132
+ /**
133
+ * Used verbatim. The vitest adapter supplies an already-qualified
134
+ * "describe > test" path, so prefixing the suite name here would print it
135
+ * twice in every failure message.
136
+ */
137
+ caseName, draft, expect, base = {}) {
138
+ return caseWithGiven(spec, caseName, toGiven(draft, base), expect);
139
+ }
140
+ /**
141
+ * The same `Case`, from a `Given` that is already resolved.
142
+ *
143
+ * A step chain's later passes cannot go through `toCase`: `toGiven` layers the
144
+ * draft over the base, so the request's original seeds would win over what the
145
+ * previous pass actually left in state — the chain would silently restart from
146
+ * the top on every step.
147
+ */
148
+ export function caseWithGiven(spec, caseName, given, expect) {
149
+ return {
150
+ name: caseName,
151
+ script: spec.script,
152
+ outcomes: spec.outcomes,
153
+ given,
154
+ expect,
155
+ };
156
+ }
157
+ /**
158
+ * AM session properties are strings — every value in the measured 23-key
159
+ * session was one, `AuthLevel: "0"` included. A number or boolean is coerced
160
+ * (it survives the round trip unambiguously); an object or array is refused,
161
+ * because `putSessionProperty` would stringify it to `[object Object]` on the
162
+ * AIC lane while a structured mock would keep it here. That divergence is the
163
+ * false-pass shape this harness exists to prevent.
164
+ */
165
+ function sessionStrings(session) {
166
+ const out = {};
167
+ for (const [key, value] of Object.entries(session)) {
168
+ if (AM_OWNED_SESSION_SET.has(key)) {
169
+ throw new Error(`rhino-local: session.${key} is set by AM, not by the caller — a mini journey that tries to override it fails the whole login with an unexplained 401. ${key === "UserId" || key === "Principals" || key === "UserToken" || key === "Principal" || key === "sun.am.UniversalIdentifier" ? "Set `state.username` instead; the session is minted for that principal" : "Remove it"}`);
170
+ }
171
+ if (value === null || typeof value === "object") {
172
+ throw new Error(`rhino-local: session.${key} must be a string — AM stores session properties as strings, so a ${value === null ? "null" : "structured value"} cannot survive the round trip`);
173
+ }
174
+ out[key] = String(value);
175
+ }
176
+ return out;
177
+ }
178
+ /**
179
+ * Who the mini journey logs in as. The principal need not exist as a managed
180
+ * object (measured 2026-09-14), so this is free; taking it from shared state
181
+ * means a suite that already sets `username` gets a session for that user
182
+ * without saying so twice.
183
+ */
184
+ export function sessionPrincipal(draft) {
185
+ const username = draft.state.shared.username;
186
+ return typeof username === "string" && username.length > 0
187
+ ? username
188
+ : DEFAULT_SESSION_PRINCIPAL;
189
+ }
@@ -0,0 +1,80 @@
1
+ import type { CallbackEffect, Expect, Given, JsonValue, RecordedEffects } from "../case/types.ts";
2
+ import type { IdmHandle } from "./types.ts";
3
+ /**
4
+ * One submitted callback value — what the client types into the callback the
5
+ * script sent. `type` names the callback it answers; the harness matches
6
+ * replies to emitted callbacks by type, in order.
7
+ */
8
+ export interface CallbackReply {
9
+ type: string;
10
+ value: JsonValue;
11
+ }
12
+ export interface StepContext<TInput> {
13
+ input: TInput;
14
+ /** 1-based position in the chain. */
15
+ step: number;
16
+ /** What this pass emitted, in order. */
17
+ callbacks: CallbackEffect[];
18
+ effects: RecordedEffects;
19
+ }
20
+ /** Everything an intermediate pass can assert, minus the outcome it cannot have. */
21
+ export type StepExpect = Omit<Expect, "outcome">;
22
+ export interface StepSpec<TInput> {
23
+ /**
24
+ * What this pass must do before it suspends. `outcome` is not offered: a
25
+ * pass that decided an outcome did not suspend, and the chain has nothing
26
+ * to reply to.
27
+ */
28
+ expect?: StepExpect;
29
+ /**
30
+ * What the client submits back. A function receives what the pass emitted,
31
+ * which is how a reply depends on the choices the script offered.
32
+ */
33
+ reply: CallbackReply[] | ((ctx: StepContext<TInput>) => CallbackReply[]);
34
+ /**
35
+ * Asserted on each lane after this pass and before the reply is submitted —
36
+ * the point of the chain is to check the world between two halves of a
37
+ * journey, not only at the end. AIC cannot dump intermediate node state
38
+ * without adding a script-visible callback, so `effects.evidence` marks
39
+ * those channels unobserved there. Fail by throwing.
40
+ */
41
+ check?: (idm: IdmHandle, ctx: StepContext<TInput>) => void | Promise<void>;
42
+ }
43
+ /**
44
+ * Seed the next pass from the pass that just suspended.
45
+ *
46
+ * MEASURED 2026-09-14 against a live tenant, one scripted decision node that
47
+ * put a value in each bucket, sent a NameCallback, and read them back on the
48
+ * resumed pass (`docs/api/09-journeys.md` → "What survives a callback round
49
+ * trip"):
50
+ *
51
+ * - **shared state survives.** `nodeState.get` returned it and `nodeState.keys()`
52
+ * still listed it.
53
+ * - **transient state does NOT.** It came back `null`, identical to a key
54
+ * nobody ever set (the control), and it was absent from `keys()`. It is
55
+ * dropped — not promoted into secure state, which is the plausible guess a
56
+ * hand-rolled mock makes and the one that turns a broken script green.
57
+ * - **`resumedFromSuspend` stays false.** It belongs to `action.suspend()`, so
58
+ * a callback round trip must not set it; seeding `true` here would put the
59
+ * local lane in a state the AIC lane cannot even reach (the AIC lane refuses
60
+ * `given.resumedFromSuspend` for exactly that reason).
61
+ *
62
+ * Secure state is carried as the previous pass left it. No next-gen script can
63
+ * write it — there is no `putSecure` — so this is local-lane bookkeeping for a
64
+ * seed the case supplied, not a claim about AM. The AIC lane skips any case
65
+ * that declares `given.secureState`.
66
+ */
67
+ export declare function carryGiven(previous: Given, effects: RecordedEffects, submitted: readonly CallbackEffect[]): Given;
68
+ /**
69
+ * Merge replies into the callbacks a pass emitted, producing what the client
70
+ * submits.
71
+ *
72
+ * An emitted callback with no reply is submitted **without a value**, so a
73
+ * script that reads it fails naming the type rather than receiving whatever
74
+ * the callback happened to carry outbound. That asymmetry is deliberate: AM
75
+ * fills an unanswered input with a per-type default (an unanswered
76
+ * HiddenValueCallback comes back holding its own `id`, measured 2026-09-14),
77
+ * and inventing those defaults for every callback type would be a mock the
78
+ * tenant does not match.
79
+ */
80
+ export declare function submittedCallbacks(emitted: readonly CallbackEffect[], replies: readonly CallbackReply[], label: string): CallbackEffect[];
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Seed the next pass from the pass that just suspended.
3
+ *
4
+ * MEASURED 2026-09-14 against a live tenant, one scripted decision node that
5
+ * put a value in each bucket, sent a NameCallback, and read them back on the
6
+ * resumed pass (`docs/api/09-journeys.md` → "What survives a callback round
7
+ * trip"):
8
+ *
9
+ * - **shared state survives.** `nodeState.get` returned it and `nodeState.keys()`
10
+ * still listed it.
11
+ * - **transient state does NOT.** It came back `null`, identical to a key
12
+ * nobody ever set (the control), and it was absent from `keys()`. It is
13
+ * dropped — not promoted into secure state, which is the plausible guess a
14
+ * hand-rolled mock makes and the one that turns a broken script green.
15
+ * - **`resumedFromSuspend` stays false.** It belongs to `action.suspend()`, so
16
+ * a callback round trip must not set it; seeding `true` here would put the
17
+ * local lane in a state the AIC lane cannot even reach (the AIC lane refuses
18
+ * `given.resumedFromSuspend` for exactly that reason).
19
+ *
20
+ * Secure state is carried as the previous pass left it. No next-gen script can
21
+ * write it — there is no `putSecure` — so this is local-lane bookkeeping for a
22
+ * seed the case supplied, not a claim about AM. The AIC lane skips any case
23
+ * that declares `given.secureState`.
24
+ */
25
+ export function carryGiven(previous, effects, submitted) {
26
+ const next = { ...previous };
27
+ next.sharedState = clone(effects.sharedState.final);
28
+ // Not `{}`: absent and empty differ elsewhere in `Given`, and "the tenant
29
+ // dropped it" is absence.
30
+ delete next.transientState;
31
+ if (Object.keys(effects.secureState.final).length > 0) {
32
+ next.secureState = clone(effects.secureState.final);
33
+ }
34
+ else {
35
+ delete next.secureState;
36
+ }
37
+ next.callbacks = submitted.map((callback) => ({ ...callback }));
38
+ if (effects.managedStore !== undefined) {
39
+ // Records the earlier pass created have to be visible to the later one,
40
+ // or a chain can never test a journey that writes and then reads back.
41
+ next.managed = clone(effects.managedStore);
42
+ }
43
+ return next;
44
+ }
45
+ /**
46
+ * Merge replies into the callbacks a pass emitted, producing what the client
47
+ * submits.
48
+ *
49
+ * An emitted callback with no reply is submitted **without a value**, so a
50
+ * script that reads it fails naming the type rather than receiving whatever
51
+ * the callback happened to carry outbound. That asymmetry is deliberate: AM
52
+ * fills an unanswered input with a per-type default (an unanswered
53
+ * HiddenValueCallback comes back holding its own `id`, measured 2026-09-14),
54
+ * and inventing those defaults for every callback type would be a mock the
55
+ * tenant does not match.
56
+ */
57
+ export function submittedCallbacks(emitted, replies, label) {
58
+ const pending = new Map();
59
+ for (const reply of replies) {
60
+ const queue = pending.get(reply.type);
61
+ if (queue === undefined) {
62
+ pending.set(reply.type, [reply.value]);
63
+ }
64
+ else {
65
+ queue.push(reply.value);
66
+ }
67
+ }
68
+ const out = [];
69
+ for (const callback of emitted) {
70
+ const queue = pending.get(callback.type);
71
+ const { value: _outbound, ...rest } = callback;
72
+ if (queue === undefined || queue.length === 0) {
73
+ out.push({ ...rest });
74
+ continue;
75
+ }
76
+ out.push({ ...rest, value: queue.shift() });
77
+ }
78
+ const leftover = [...pending.entries()].filter(([, queue]) => queue.length > 0);
79
+ if (leftover.length > 0) {
80
+ const emittedCounts = countByType(emitted);
81
+ const detail = leftover
82
+ .map(([type, queue]) => `${queue.length} unused ${type} ${queue.length === 1 ? "reply" : "replies"} (the pass emitted ${emittedCounts.get(type) ?? 0})`)
83
+ .join("; ");
84
+ throw new Error(`rhino-local: ${label} replied to callbacks the pass did not send — ${detail}. A reply nobody asked for would seed the next pass with a value the tenant could never deliver`);
85
+ }
86
+ return out;
87
+ }
88
+ function countByType(callbacks) {
89
+ const counts = new Map();
90
+ for (const callback of callbacks) {
91
+ counts.set(callback.type, (counts.get(callback.type) ?? 0) + 1);
92
+ }
93
+ return counts;
94
+ }
95
+ function clone(value) {
96
+ return JSON.parse(JSON.stringify(value));
97
+ }
@@ -0,0 +1,100 @@
1
+ import type { z } from "zod";
2
+ import type { Expect, Given, JsonObject, JsonValue } from "../case/types.ts";
3
+ /** A header or parameter value. An array is sent as repeated occurrences. */
4
+ export type WireValue = string | readonly string[];
5
+ export type WireMap = Readonly<Record<string, WireValue>>;
6
+ export interface StateChannels {
7
+ shared?: JsonObject;
8
+ transient?: JsonObject;
9
+ }
10
+ /**
11
+ * Everything a run can carry into the script, in one shape. The same five
12
+ * channels are declarable on the suite (`always`), overridable per test, and
13
+ * reachable from `beforeRun` — so there is one place to look, whichever
14
+ * surface you are reading.
15
+ */
16
+ export interface Channels {
17
+ state?: StateChannels;
18
+ /**
19
+ * ESV overrides. NOT the `systemEnv` binding — these compile to shared
20
+ * state under `esv.<name>`, which the tenant's config library consults
21
+ * before falling back to the real ESV. Seed the real binding with
22
+ * `given.esv` instead; both mechanisms exist and mean different things.
23
+ */
24
+ esv?: Readonly<Record<string, string>>;
25
+ /** Sent on the authenticate request. No script can assign these bindings. */
26
+ headers?: WireMap;
27
+ /** Sent on the authenticate request. */
28
+ params?: WireMap;
29
+ /**
30
+ * `existingSession` — session properties the script sees, as a flat string
31
+ * map. Declaring it at all (`session: {}` included) asks for a logged-in
32
+ * session; the AIC lane pays for it with an extra round trip, because a
33
+ * session only exists once a journey has run to completion, so the lane
34
+ * runs a two-line mini journey and forwards its cookie to the subject.
35
+ *
36
+ * Custom properties only. The ones AM sets itself — `UserId`, `AuthLevel`
37
+ * and the rest — are refused, because `putSessionProperty` cannot override
38
+ * them and trying fails the whole login with an unexplained 401. The five
39
+ * AM derives from the principal come from `state.username` instead.
40
+ *
41
+ * Nothing about the subject tree changes, and the principal need not exist
42
+ * as a managed object (both measured 2026-09-14).
43
+ */
44
+ session?: JsonObject;
45
+ }
46
+ /** The mutable draft `beforeRun` is handed. */
47
+ export interface RequestDraft {
48
+ state: {
49
+ shared: JsonObject;
50
+ transient: JsonObject;
51
+ };
52
+ esv: Record<string, string>;
53
+ headers: Record<string, string[]>;
54
+ params: Record<string, string[]>;
55
+ session: JsonObject;
56
+ /** Whether either level asked for a session at all. See mergeChannels. */
57
+ sessionRequested: boolean;
58
+ }
59
+ /** A managed record the harness creates and is therefore responsible for. */
60
+ export interface FixtureSpec {
61
+ type: string;
62
+ record: JsonObject;
63
+ }
64
+ export interface BeforeRunContext<TInput> {
65
+ input: TInput;
66
+ request: RequestDraft;
67
+ fixtures: FixtureCreator;
68
+ }
69
+ export interface FixtureCreator {
70
+ create(type: string, record: JsonObject | JsonObject[]): Promise<void>;
71
+ }
72
+ /**
73
+ * What checks and cleanup receive on both lanes. The surface is shared, but
74
+ * record shape is not projected: AIC reads retain tenant-materialized fields.
75
+ */
76
+ export interface IdmHandle {
77
+ read(resource: string): Promise<JsonObject | null>;
78
+ query(type: string, filter: Readonly<Record<string, JsonValue>>): Promise<JsonObject[]>;
79
+ delete(resource: string): Promise<void>;
80
+ }
81
+ export interface CleanupContext<TInput> {
82
+ input: TInput;
83
+ }
84
+ export interface SuiteSpec<TSchema extends z.ZodType> {
85
+ name: string;
86
+ /** Author source, already loaded. */
87
+ script: string;
88
+ /** The outcome vocabulary. Required here — a lease has to declare it. */
89
+ outcomes: readonly string[];
90
+ inputs?: TSchema;
91
+ always?: Channels;
92
+ fixtures?: Readonly<Record<string, FixtureSpec>>;
93
+ beforeRun?: (ctx: BeforeRunContext<z.output<TSchema>>) => void | Promise<void>;
94
+ cleanup?: (idm: IdmHandle, ctx: CleanupContext<z.output<TSchema>>) => Promise<void>;
95
+ }
96
+ /** Per-test overrides, merged over the suite's `always`. */
97
+ export interface RunOverrides extends Channels {
98
+ expect: Expect;
99
+ }
100
+ export type { Expect, Given, JsonObject, JsonValue };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import type { z } from "zod";
2
+ import type { AicIo } from "../aic/tenant.ts";
3
+ import type { TenantProvider } from "../aic/provider.ts";
4
+ import { Lease, type LeaseOptions, type Suite } from "./lease.ts";
5
+ export interface UseLeaseAicOptions {
6
+ id: string;
7
+ realm?: string;
8
+ tenant?: string;
9
+ project?: string;
10
+ /** Where the tenant and its bearer come from; see `resolveTenantProvider`. */
11
+ provider?: TenantProvider;
12
+ unsupported?: "fail" | "skip";
13
+ }
14
+ export interface UseLeaseOptions extends Partial<Omit<LeaseOptions, "runner" | "lane" | "realm">> {
15
+ spawnTimeoutMs?: number;
16
+ aic?: UseLeaseAicOptions;
17
+ /** Injected fake seam for adapter tests; production uses the default AIC I/O. */
18
+ aicIo?: AicIo;
19
+ /** Injected filesystem seam for adapter tests. */
20
+ aicStateDir?: string;
21
+ }
22
+ /** The env var that opts a run into the tenant lane. */
23
+ export declare const AIC_LANE_ENV = "AIC_SCRIPT_TESTER_AIC";
24
+ /**
25
+ * Spread into `useLease` options to run both lanes when `AIC_SCRIPT_TESTER_AIC=1`.
26
+ *
27
+ * Off by default, deliberately. The tenant lane needs an unlocked agent and
28
+ * network, which CI has neither of, and a checkout without them should still
29
+ * be able to run the whole suite offline — so opting in is a per-invocation
30
+ * decision, not a property of the file:
31
+ *
32
+ * npm test # local lane only, as before
33
+ * AIC_SCRIPT_TESTER_AIC=1 npm test # both lanes, against the sandbox
34
+ *
35
+ * `id` must be unique per file: it seeds the deterministic resource ids, and
36
+ * one AIC lease per file is enforced.
37
+ */
38
+ export declare function aicWhenEnabled(id: string, realm?: string): {
39
+ aic?: UseLeaseAicOptions;
40
+ };
41
+ /**
42
+ * Take a lease for one test file, and register its own lifecycle.
43
+ *
44
+ * The hooks are registered here rather than left to the author because
45
+ * forgetting teardown is the one mistake that leaks tenant state, and it
46
+ * leaks silently — the suite still passes. Owning the hooks makes the leak
47
+ * impossible to cause by omission. The same applies to failure records: the
48
+ * author must not have to remember to write the transaction id down.
49
+ */
50
+ export declare function useLease<TSchema extends z.ZodType>(suite: Suite<TSchema>, options?: UseLeaseOptions): Lease<TSchema>;
51
+ export declare function claimAicLeaseForFile(file: string, id: string): void;
52
+ export declare function releaseAicLeaseForFile(file: string, id?: string): void;