@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,286 @@
1
+ import { judge } from "../case/index.js";
2
+ import { runCase } from "../bindings/index.js";
3
+ import { ledgerToManaged, localIdmHandle } from "./idm.js";
4
+ import { describeResidue, findResidue } from "./residue.js";
5
+ import { applyInputsAndEsv, caseWithGiven, mergeChannels, normaliseWire, parseInputs, toGiven, } from "./spec.js";
6
+ import { carryGiven, submittedCallbacks } from "./step.js";
7
+ /**
8
+ * One test's run, assembled lazily.
9
+ *
10
+ * Deliberately NOT a thenable. A builder that is also a promise invites an
11
+ * `await` halfway through the chain, after which `.check()` is appending to a
12
+ * run that has already settled — a bug that reads as working code. Making
13
+ * `.expect()` the only terminal turns that whole class into a type error.
14
+ */
15
+ export class RunBuilder {
16
+ #lease;
17
+ #testName;
18
+ #rawInput;
19
+ #override = {};
20
+ #checks = [];
21
+ #steps = [];
22
+ constructor(lease, testName, rawInput) {
23
+ this.#lease = lease;
24
+ this.#testName = testName;
25
+ this.#rawInput = rawInput;
26
+ }
27
+ state(state) {
28
+ this.#override = { ...this.#override, state: mergeState(this.#override.state, state) };
29
+ return this;
30
+ }
31
+ esv(esv) {
32
+ this.#override = { ...this.#override, esv: { ...this.#override.esv, ...esv } };
33
+ return this;
34
+ }
35
+ headers(headers) {
36
+ this.#override = { ...this.#override, headers: { ...this.#override.headers, ...headers } };
37
+ return this;
38
+ }
39
+ params(params) {
40
+ this.#override = { ...this.#override, params: { ...this.#override.params, ...params } };
41
+ return this;
42
+ }
43
+ session(session) {
44
+ this.#override = { ...this.#override, session: { ...this.#override.session, ...session } };
45
+ return this;
46
+ }
47
+ /**
48
+ * Declare one suspended pass: what the script must send, what the client
49
+ * sends back, and what must be true of the world in between.
50
+ *
51
+ * Passes run in declaration order and the terminal `.expect()` judges the
52
+ * one after the last step, so a two-callback journey is two `.step()` calls
53
+ * and one `.expect()`. A step whose expectations fail aborts the chain
54
+ * rather than replying anyway: every later pass is seeded from this one, so
55
+ * continuing would report a cascade of failures that all trace back here.
56
+ */
57
+ step(spec) {
58
+ this.#steps.push(spec);
59
+ return this;
60
+ }
61
+ /** The same, for a chain built from data. */
62
+ steps(specs) {
63
+ for (const spec of specs) {
64
+ this.#steps.push(spec);
65
+ }
66
+ return this;
67
+ }
68
+ /** Assert against each lane's IDM store and response. Fail by throwing. */
69
+ check(fn) {
70
+ this.#checks.push(fn);
71
+ return this;
72
+ }
73
+ /** The only terminal. */
74
+ async expect(expected) {
75
+ return this.#lease.execute(this.#testName, this.#rawInput, this.#override, expected, this.#checks, this.#steps);
76
+ }
77
+ }
78
+ export class Lease {
79
+ #spec;
80
+ #options;
81
+ /** Everything the harness created, in creation order. */
82
+ #suiteLedger = [];
83
+ #testLedger = [];
84
+ constructor(spec, options) {
85
+ this.#spec = spec;
86
+ this.#options = options;
87
+ }
88
+ /** Suite-scoped fixtures, created once before the first test. */
89
+ open() {
90
+ this.#suiteLedger = [];
91
+ for (const fixture of Object.values(this.#spec.fixtures ?? {})) {
92
+ this.#suiteLedger.push(fixture);
93
+ }
94
+ }
95
+ fixtures = {
96
+ create: (type, record) => {
97
+ for (const one of Array.isArray(record) ? record : [record]) {
98
+ this.#testLedger.push({ type, record: one });
99
+ }
100
+ return Promise.resolve();
101
+ },
102
+ };
103
+ run(input) {
104
+ return new RunBuilder(this, this.#options.testName?.() ?? this.#spec.name, input);
105
+ }
106
+ /**
107
+ * Drained after every test. The per-test ledger goes; the suite's stays
108
+ * until close(). Clearing only at teardown would let test 3 see test 1's
109
+ * records, which is the cross-test interference this design exists to
110
+ * remove.
111
+ */
112
+ async endTest() {
113
+ try {
114
+ await this.#options.lane?.endTest();
115
+ }
116
+ finally {
117
+ this.#testLedger = [];
118
+ }
119
+ }
120
+ /** Suite teardown: drop what the suite created, alongside the journey. */
121
+ close() {
122
+ this.#testLedger = [];
123
+ this.#suiteLedger = [];
124
+ return Promise.resolve();
125
+ }
126
+ ledger() {
127
+ return [...this.#suiteLedger, ...this.#testLedger];
128
+ }
129
+ async execute(testName, rawInput, override, expected, checks, steps = []) {
130
+ const input = parseInputs(this.#spec, rawInput);
131
+ const draft = mergeChannels(this.#spec.always, override);
132
+ if (this.#spec.beforeRun !== undefined) {
133
+ await this.#spec.beforeRun({
134
+ input: input,
135
+ request: draft,
136
+ fixtures: this.fixtures,
137
+ });
138
+ }
139
+ applyInputsAndEsv(draft, input);
140
+ const ledger = this.ledger();
141
+ let given = toGiven(draft, {
142
+ managed: ledgerToManaged(ledger),
143
+ ...(this.#options.realm === undefined
144
+ ? {}
145
+ : { realm: this.#options.realm }),
146
+ }, this.#options.realm);
147
+ if (steps.length > 0 && given.callbacks === undefined) {
148
+ // Declaring a step says the script suspends, and a script that suspends
149
+ // reads `callbacks` to tell its first pass from its later ones. AM's
150
+ // first pass carries an empty list, so seed one rather than leaving the
151
+ // binding unseeded and failing on a read the chain guarantees.
152
+ given = { ...given, callbacks: [] };
153
+ }
154
+ const stepResults = [];
155
+ for (const [index, step] of steps.entries()) {
156
+ given = await this.#runStep(testName, index, step, given, input, stepResults);
157
+ }
158
+ const kase = caseWithGiven(this.#spec, testName, given, expected);
159
+ const effects = await runCase(this.#options.runner, kase, {
160
+ ...(this.#options.timeoutMs !== undefined ? { timeoutMs: this.#options.timeoutMs } : {}),
161
+ });
162
+ const verdict = judge(kase, effects.effects);
163
+ const store = cloneStore(effects.effects.managedStore);
164
+ const idm = localIdmHandle(store ?? {}, () => undefined);
165
+ try {
166
+ for (const check of checks) {
167
+ await check(idm, { input, effects: effects.effects });
168
+ }
169
+ }
170
+ finally {
171
+ if (this.#spec.cleanup !== undefined) {
172
+ await this.#spec.cleanup(idm, { input: input });
173
+ }
174
+ }
175
+ // The store is diffed AFTER cleanup ran, which is the only ordering that
176
+ // tests the cleanup rather than the script.
177
+ if (this.#options.allowResidue !== true) {
178
+ const residue = findResidue(store, ledger);
179
+ if (residue.length > 0) {
180
+ throw new Error(`rhino-local: ${kase.name}\n ${describeResidue(residue)}`);
181
+ }
182
+ }
183
+ const result = {
184
+ kase,
185
+ effects: effects.effects,
186
+ verdict,
187
+ fixtures: ledger,
188
+ steps: stepResults,
189
+ };
190
+ if (this.#options.lane !== undefined) {
191
+ const conformance = await this.#options.lane.run({
192
+ result,
193
+ source: this.#spec.script,
194
+ hooks: this.#laneHooks(input, checks, steps),
195
+ });
196
+ result.conformance = conformance;
197
+ }
198
+ return result;
199
+ }
200
+ /**
201
+ * Run one suspended pass and return the seed for the next.
202
+ *
203
+ * The step's `check` is handed the run's own store rather than a copy, so a
204
+ * record it deletes really is gone from the pass that follows. A copy would
205
+ * let a cleanup written between two halves of a journey look like it worked
206
+ * while the next pass still saw the record.
207
+ */
208
+ async #runStep(testName, index, step, given, input, results) {
209
+ const kase = caseWithGiven(this.#spec, `${testName} [step ${index + 1}]`, given, {
210
+ ...(step.expect ?? {}),
211
+ outcome: null,
212
+ });
213
+ const run = await runCase(this.#options.runner, kase, {
214
+ ...(this.#options.timeoutMs !== undefined ? { timeoutMs: this.#options.timeoutMs } : {}),
215
+ });
216
+ const verdict = judge(kase, run.effects);
217
+ if (!verdict.pass) {
218
+ throw new Error(`rhino-local: ${kase.name}\n${verdict.summary}`);
219
+ }
220
+ const context = {
221
+ input,
222
+ step: index + 1,
223
+ callbacks: run.effects.callbacks,
224
+ effects: run.effects,
225
+ };
226
+ if (step.check !== undefined) {
227
+ if (run.effects.managedStore === undefined) {
228
+ run.effects.managedStore = {};
229
+ }
230
+ await step.check(localIdmHandle(run.effects.managedStore, () => undefined), context);
231
+ }
232
+ const replies = typeof step.reply === "function" ? step.reply(context) : step.reply;
233
+ const submitted = submittedCallbacks(run.effects.callbacks, replies, kase.name);
234
+ results.push({ kase, effects: run.effects, verdict, submitted });
235
+ return carryGiven(given, run.effects, submitted);
236
+ }
237
+ #laneHooks(input, checks, steps) {
238
+ const cleanup = this.#spec.cleanup;
239
+ return {
240
+ stepChecks: steps.map((step, index) => {
241
+ const check = step.check;
242
+ return check === undefined
243
+ ? undefined
244
+ : (idm, effects) => check(idm, {
245
+ input,
246
+ step: index + 1,
247
+ callbacks: effects.callbacks,
248
+ effects,
249
+ });
250
+ }),
251
+ finalChecks: checks.map((check) => (idm, effects) => check(idm, { input, effects })),
252
+ ...(cleanup === undefined
253
+ ? {}
254
+ : {
255
+ cleanup: (idm) => cleanup(idm, {
256
+ input: input,
257
+ }),
258
+ }),
259
+ };
260
+ }
261
+ }
262
+ export function defineSuite(spec) {
263
+ if (spec.outcomes.length === 0) {
264
+ throw new Error(`rhino-local: suite ${JSON.stringify(spec.name)} must declare its outcomes — a tenant answers an undeclared outcome with a bare 401 and no callback`);
265
+ }
266
+ return {
267
+ spec,
268
+ lease: (options) => new Lease(spec, options),
269
+ };
270
+ }
271
+ /** A managed record the suite creates once, for the whole file. */
272
+ export function managed(type, record) {
273
+ return { type, record };
274
+ }
275
+ function mergeState(a, b) {
276
+ return {
277
+ shared: { ...(a?.shared ?? {}), ...(b?.shared ?? {}) },
278
+ transient: { ...(a?.transient ?? {}), ...(b?.transient ?? {}) },
279
+ };
280
+ }
281
+ function cloneStore(store) {
282
+ return store === undefined
283
+ ? undefined
284
+ : JSON.parse(JSON.stringify(store));
285
+ }
286
+ export { normaliseWire };
@@ -0,0 +1,23 @@
1
+ import type { JsonObject } from "../case/types.ts";
2
+ import type { FixtureSpec } from "./types.ts";
3
+ export interface ResidueEntry {
4
+ resource: string;
5
+ reason: "created-by-script" | "mutated-by-script";
6
+ }
7
+ /**
8
+ * Identify records that survived a test which the harness did not create.
9
+ *
10
+ * This is the check that stops `cleanup()` rotting. A cleanup function nobody
11
+ * verifies drifts the moment the script grows a new write, and on a tenant the
12
+ * drift is invisible — the harness cannot see a record it has no id for. The
13
+ * local lane can: it owns the whole store, so the residue is simply what is
14
+ * left over after removing everything the ledger accounts for.
15
+ *
16
+ * Compared against the **store**, not the recorded call log. Matching
17
+ * `create` calls to `delete` calls checks intent, and would call a test clean
18
+ * when its cleanup deleted a different id than the one the script wrote. It
19
+ * would also miss a script that mutated a record it never created, which
20
+ * leaves the tenant just as dirty as an extra row does.
21
+ */
22
+ export declare function findResidue(store: Record<string, JsonObject[]> | undefined, ledger: readonly FixtureSpec[]): ResidueEntry[];
23
+ export declare function describeResidue(entries: readonly ResidueEntry[]): string;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Identify records that survived a test which the harness did not create.
3
+ *
4
+ * This is the check that stops `cleanup()` rotting. A cleanup function nobody
5
+ * verifies drifts the moment the script grows a new write, and on a tenant the
6
+ * drift is invisible — the harness cannot see a record it has no id for. The
7
+ * local lane can: it owns the whole store, so the residue is simply what is
8
+ * left over after removing everything the ledger accounts for.
9
+ *
10
+ * Compared against the **store**, not the recorded call log. Matching
11
+ * `create` calls to `delete` calls checks intent, and would call a test clean
12
+ * when its cleanup deleted a different id than the one the script wrote. It
13
+ * would also miss a script that mutated a record it never created, which
14
+ * leaves the tenant just as dirty as an extra row does.
15
+ */
16
+ export function findResidue(store, ledger) {
17
+ if (store === undefined) {
18
+ return [];
19
+ }
20
+ const accounted = new Map();
21
+ for (const fixture of ledger) {
22
+ const id = recordId(fixture.record);
23
+ if (id !== undefined) {
24
+ accounted.set(`${fixture.type}/${id}`, fixture.record);
25
+ }
26
+ }
27
+ const residue = [];
28
+ for (const [collection, rows] of Object.entries(store)) {
29
+ for (const row of rows) {
30
+ const id = recordId(row);
31
+ const resource = id === undefined ? collection : `${collection}/${id}`;
32
+ const seeded = id === undefined ? undefined : accounted.get(resource);
33
+ if (seeded === undefined) {
34
+ residue.push({ resource, reason: "created-by-script" });
35
+ continue;
36
+ }
37
+ if (!sameRecord(seeded, row)) {
38
+ residue.push({ resource, reason: "mutated-by-script" });
39
+ }
40
+ }
41
+ }
42
+ return residue;
43
+ }
44
+ export function describeResidue(entries) {
45
+ if (entries.length === 0) {
46
+ return "";
47
+ }
48
+ const lines = entries.map((entry) => ` ${entry.resource} (${entry.reason.replace(/-/g, " ")})`);
49
+ const count = entries.length === 1 ? "1 record" : `${entries.length} records`;
50
+ return [
51
+ `${count} survived the test that the harness did not create:`,
52
+ ...lines,
53
+ " Add them to the suite's cleanup(), or set allowResidue if deliberate.",
54
+ " On the AIC lane this would leak permanently and invisibly.",
55
+ ].join("\n");
56
+ }
57
+ function recordId(record) {
58
+ const id = record._id;
59
+ return typeof id === "string" ? id : undefined;
60
+ }
61
+ /**
62
+ * The mock stores what the script wrote, which may carry fields the fixture
63
+ * never set (`_rev`, server defaults). Comparing only the keys the fixture
64
+ * declared keeps those from reading as mutations the script made.
65
+ */
66
+ function sameRecord(seeded, stored) {
67
+ for (const [key, value] of Object.entries(seeded)) {
68
+ if (JSON.stringify(stored[key]) !== JSON.stringify(value)) {
69
+ return false;
70
+ }
71
+ }
72
+ return true;
73
+ }
@@ -0,0 +1,43 @@
1
+ import { type FailureRecord } from "./failures.ts";
2
+ export interface TxChoice {
3
+ id: string;
4
+ label: string;
5
+ }
6
+ export interface ShowLogIo {
7
+ readDump: () => Promise<FailureRecord[]>;
8
+ print: (text: string) => void;
9
+ error: (text: string) => void;
10
+ selectTests: (records: FailureRecord[]) => Promise<FailureRecord[]>;
11
+ selectIds: (record: FailureRecord, choices: TxChoice[]) => Promise<string[]>;
12
+ /** Every log event for `id` on the tenant a failure record names. */
13
+ readTransaction: (tenant: string, id: string) => Promise<unknown[]>;
14
+ openEditor: (editor: string, file: string) => Promise<void>;
15
+ writeView: (contents: string) => Promise<string>;
16
+ env: NodeJS.ProcessEnv;
17
+ }
18
+ export declare function txChoices(record: FailureRecord): TxChoice[];
19
+ export declare function resolveLogsEditor(env: NodeJS.ProcessEnv): string | undefined;
20
+ /**
21
+ * No tenant column: a tenant's name defaults to its hostname, and this list
22
+ * reaches stdout (and so CI logs) whether or not `--stdout` was asked for.
23
+ */
24
+ export declare function formatFailureList(records: readonly FailureRecord[]): string;
25
+ export declare function formatChoices(choices: readonly TxChoice[]): string;
26
+ /**
27
+ * Empty input selects the first entry (newest test / the stem). `all` selects
28
+ * every entry. Invalid input returns undefined so the caller can re-prompt.
29
+ */
30
+ export declare function parseSelection(input: string, count: number, empty?: "first" | "all"): number[] | undefined;
31
+ export interface ShowLogOptions {
32
+ /**
33
+ * Print the log bodies instead of writing the view. Off by default: the
34
+ * bodies are tenant data, and the view is a 0600 file in the self-ignoring
35
+ * state directory, where a CI log is neither.
36
+ */
37
+ stdout?: boolean;
38
+ }
39
+ export declare function runShowLog(io: ShowLogIo, options?: ShowLogOptions): Promise<number>;
40
+ export declare function createDefaultIo(options?: {
41
+ env?: NodeJS.ProcessEnv;
42
+ project?: string;
43
+ }): ShowLogIo;
@@ -0,0 +1,253 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmod, writeFile } from "node:fs/promises";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { checkTenantDescription, configuredTenantProvider, } from "../aic/provider.js";
5
+ import { aicCliProvider, defaultAicIo } from "../aic/tenant.js";
6
+ import { latestLogsPath, projectRoot } from "../project.js";
7
+ import { readFailures, sortNewestFirst, } from "./failures.js";
8
+ export function txChoices(record) {
9
+ if (record.passIds.length <= 1) {
10
+ return [{ id: record.stem, label: "whole chain" }];
11
+ }
12
+ return [
13
+ { id: record.stem, label: "whole chain" },
14
+ ...record.passIds.map((id, index) => ({
15
+ id,
16
+ label: `pass ${index + 1}`,
17
+ })),
18
+ ];
19
+ }
20
+ export function resolveLogsEditor(env) {
21
+ return nonEmpty(env.LOGS_EDITOR) ?? nonEmpty(env.EDITOR);
22
+ }
23
+ /**
24
+ * No tenant column: a tenant's name defaults to its hostname, and this list
25
+ * reaches stdout (and so CI logs) whether or not `--stdout` was asked for.
26
+ */
27
+ export function formatFailureList(records) {
28
+ return records
29
+ .map((record, index) => {
30
+ const n = String(index + 1).padStart(String(records.length).length, " ");
31
+ return `${n}. ${record.timestamp} ${record.testName}`;
32
+ })
33
+ .join("\n");
34
+ }
35
+ export function formatChoices(choices) {
36
+ return choices
37
+ .map((choice, index) => `${index + 1}. ${choice.id} (${choice.label})`)
38
+ .join("\n");
39
+ }
40
+ /**
41
+ * Empty input selects the first entry (newest test / the stem). `all` selects
42
+ * every entry. Invalid input returns undefined so the caller can re-prompt.
43
+ */
44
+ export function parseSelection(input, count, empty = "first") {
45
+ if (count < 1) {
46
+ return [];
47
+ }
48
+ const trimmed = input.trim().toLowerCase();
49
+ if (trimmed === "") {
50
+ return empty === "all" ? range(0, count) : [0];
51
+ }
52
+ if (trimmed === "all") {
53
+ return range(0, count);
54
+ }
55
+ const picked = [];
56
+ const seen = new Set();
57
+ for (const part of trimmed.split(",")) {
58
+ const piece = part.trim();
59
+ const span = /^(\d+)-(\d+)$/.exec(piece);
60
+ if (span !== null) {
61
+ const start = Number(span[1]);
62
+ const end = Number(span[2]);
63
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end > count || start > end) {
64
+ return undefined;
65
+ }
66
+ for (let index = start - 1; index < end; index += 1) {
67
+ if (!seen.has(index)) {
68
+ seen.add(index);
69
+ picked.push(index);
70
+ }
71
+ }
72
+ continue;
73
+ }
74
+ const n = Number(piece);
75
+ if (!Number.isInteger(n) || n < 1 || n > count) {
76
+ return undefined;
77
+ }
78
+ const index = n - 1;
79
+ if (!seen.has(index)) {
80
+ seen.add(index);
81
+ picked.push(index);
82
+ }
83
+ }
84
+ return picked.length === 0 ? undefined : picked;
85
+ }
86
+ export async function runShowLog(io, options = {}) {
87
+ const records = sortNewestFirst(await io.readDump());
88
+ if (records.length === 0) {
89
+ io.print("no failed tests recorded. Run a test that hits the AIC lane first.");
90
+ return 0;
91
+ }
92
+ io.print(formatFailureList(records));
93
+ const onlyRecord = records[0];
94
+ const selected = records.length === 1 && onlyRecord !== undefined
95
+ ? [onlyRecord]
96
+ : await io.selectTests(records);
97
+ if (selected.length === 0) {
98
+ io.print("nothing selected");
99
+ return 0;
100
+ }
101
+ const chunks = [];
102
+ for (const record of selected) {
103
+ const choices = txChoices(record);
104
+ const onlyChoice = choices[0];
105
+ const ids = choices.length === 1 && onlyChoice !== undefined
106
+ ? [onlyChoice.id]
107
+ : await io.selectIds(record, choices);
108
+ for (const id of ids) {
109
+ let events;
110
+ try {
111
+ events = await io.readTransaction(record.tenant, id);
112
+ }
113
+ catch (error) {
114
+ io.error(error instanceof Error ? error.message : String(error));
115
+ return 1;
116
+ }
117
+ const body = JSON.stringify(events, null, 2);
118
+ chunks.push(ids.length === 1 && selected.length === 1
119
+ ? body
120
+ : `=== ${id} (${record.testName}) ===\n${body}`);
121
+ }
122
+ }
123
+ const text = `${chunks.join("\n\n")}\n`;
124
+ if (options.stdout === true) {
125
+ io.print(text);
126
+ return 0;
127
+ }
128
+ const viewPath = await io.writeView(text);
129
+ const editor = resolveLogsEditor(io.env);
130
+ if (editor === undefined) {
131
+ io.print(`wrote ${viewPath}\nSet LOGS_EDITOR or EDITOR to open it, or pass --stdout to print the logs instead.`);
132
+ return 0;
133
+ }
134
+ await io.openEditor(editor, viewPath);
135
+ return 0;
136
+ }
137
+ export function createDefaultIo(options = {}) {
138
+ const env = options.env ?? process.env;
139
+ const project = options.project ?? projectRoot(env);
140
+ const providers = new Map();
141
+ return {
142
+ env,
143
+ readDump: () => readFailures(),
144
+ print: (text) => {
145
+ process.stdout.write(`${text}\n`);
146
+ },
147
+ error: (text) => {
148
+ process.stderr.write(`${text}\n`);
149
+ },
150
+ async selectTests(records) {
151
+ process.stdout.write("\nSelect test (comma-separated, all) [1]: ");
152
+ const picked = await readSelection(records.length);
153
+ return picked.flatMap((index) => {
154
+ const record = records[index];
155
+ return record === undefined ? [] : [record];
156
+ });
157
+ },
158
+ async selectIds(record, choices) {
159
+ process.stdout.write(`\n${record.testName}\n${formatChoices(choices)}\n`);
160
+ process.stdout.write("Select transaction id(s) [1 = whole chain]: ");
161
+ const picked = await readSelection(choices.length);
162
+ return picked.flatMap((index) => {
163
+ const id = choices[index]?.id;
164
+ return id === undefined ? [] : [id];
165
+ });
166
+ },
167
+ async readTransaction(tenant, id) {
168
+ // One provider per tenant for the whole session, so an `aic` context is
169
+ // listed once rather than once per selected transaction.
170
+ let provider = providers.get(tenant);
171
+ if (provider === undefined) {
172
+ provider =
173
+ configuredTenantProvider(env) ??
174
+ aicCliProvider(defaultAicIo(project), { tenant, project });
175
+ providers.set(tenant, provider);
176
+ }
177
+ const { name } = checkTenantDescription(await provider.describe());
178
+ if (name !== tenant) {
179
+ throw new Error("show-log: this failure ran on a different tenant from the one the configured tenant provider serves");
180
+ }
181
+ if (provider.logs === undefined) {
182
+ throw new Error("show-log: the configured tenant provider cannot read logs. Give it logKeys (or set AIC_SCRIPT_TESTER_LOG_KEY_ID and AIC_SCRIPT_TESTER_LOG_KEY_SECRET).");
183
+ }
184
+ return provider.logs.transaction(id);
185
+ },
186
+ openEditor,
187
+ async writeView(contents) {
188
+ const path = latestLogsPath(env);
189
+ // 0600: this holds a live tenant's log bodies — hostnames, client ips,
190
+ // request headers. The directory is gitignored, but the file should not
191
+ // be world-readable either.
192
+ await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
193
+ await chmod(path, 0o600);
194
+ return path;
195
+ },
196
+ };
197
+ }
198
+ async function readSelection(count) {
199
+ if (process.stdin.isTTY !== true) {
200
+ throw new Error("show-log: need a terminal to select among multiple entries. Re-run from a tty.");
201
+ }
202
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
203
+ try {
204
+ const answer = await rl.question("");
205
+ const picked = parseSelection(answer, count);
206
+ if (picked === undefined) {
207
+ throw new Error(`show-log: could not parse ${JSON.stringify(answer)}`);
208
+ }
209
+ return picked;
210
+ }
211
+ finally {
212
+ rl.close();
213
+ }
214
+ }
215
+ /**
216
+ * `shell: true` is deliberate — `$EDITOR` is routinely a command line
217
+ * (`code -w`, `vim -p`), not a bare executable. But an args array WITH a shell
218
+ * is what Node deprecated in DEP0190, because the shell re-splits what was
219
+ * already separated. So build the one command string the shell will actually
220
+ * run, and quote the path ourselves.
221
+ */
222
+ function openEditor(editor, file) {
223
+ const command = `${editor} ${shellQuote(file)}`;
224
+ return new Promise((resolve, reject) => {
225
+ const child = spawn(command, { stdio: "inherit", shell: true });
226
+ child.on("error", reject);
227
+ child.on("exit", (code) => {
228
+ if (code === 0 || code === null) {
229
+ resolve();
230
+ return;
231
+ }
232
+ reject(new Error(`${editor} exited ${code}`));
233
+ });
234
+ });
235
+ }
236
+ function nonEmpty(value) {
237
+ if (value === undefined) {
238
+ return undefined;
239
+ }
240
+ const trimmed = value.trim();
241
+ return trimmed.length === 0 ? undefined : trimmed;
242
+ }
243
+ function range(start, end) {
244
+ const out = [];
245
+ for (let index = start; index < end; index += 1) {
246
+ out.push(index);
247
+ }
248
+ return out;
249
+ }
250
+ /** Single-quote for a POSIX shell, closing and reopening around any quote. */
251
+ function shellQuote(value) {
252
+ return `'${value.replaceAll("'", `'\\''`)}'`;
253
+ }