@intentius/chant 0.61.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/operator.d.ts +13 -18
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +117 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +394 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +34 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
package/src/op/gate.ts
CHANGED
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* pre-authorize a future run's gate that has since been recorded pending; a
|
|
20
20
|
* resolution from last month does not answer the fact this run just wrote.
|
|
21
21
|
*
|
|
22
|
+
* Since #2300 a gate can also bind a plan. `GateCheckInput.planDigest` is
|
|
23
|
+
* what this run's Plan phase produced (`../lifecycle/plan-digest.ts`), and a
|
|
24
|
+
* resolution counts only when it was recorded for that same plan — recency
|
|
25
|
+
* demoted from the criterion to the tiebreak. Approve, edit the root, re-run,
|
|
26
|
+
* and the gate refuses by name instead of applying something no approver saw,
|
|
27
|
+
* which is what INTENTIUS/choudoufu#1026 measured it doing. A gate with no
|
|
28
|
+
* `planDigest` decides exactly as it did before.
|
|
29
|
+
*
|
|
22
30
|
* Ledger access goes through {@link GateLedgerPort} rather than straight to
|
|
23
31
|
* git, so a test (and the operator's own in-memory paths) can drive the
|
|
24
32
|
* decision without an orphan branch on disk.
|
|
@@ -28,7 +36,7 @@ import {
|
|
|
28
36
|
appendPendingGate,
|
|
29
37
|
isPendingGateExpired,
|
|
30
38
|
latestPendingGate,
|
|
31
|
-
|
|
39
|
+
latestResolutionForPlan,
|
|
32
40
|
readGateLedger,
|
|
33
41
|
resolveApprovalUrl,
|
|
34
42
|
DEFAULT_GATE_EXPIRY,
|
|
@@ -36,32 +44,80 @@ import {
|
|
|
36
44
|
type PendingGateInput,
|
|
37
45
|
type PendingGateRecord,
|
|
38
46
|
} from "../lifecycle/gate-ledger";
|
|
39
|
-
import {
|
|
47
|
+
import { describePlanDigest } from "../lifecycle/plan-digest";
|
|
48
|
+
import { pushLifecycle, requireLifecycleLedger } from "../lifecycle/git";
|
|
40
49
|
import { parseDuration } from "./duration";
|
|
41
50
|
|
|
51
|
+
/**
|
|
52
|
+
* What appending a pending fact learned about reaching the remote.
|
|
53
|
+
*
|
|
54
|
+
* The append to the local `chant/lifecycle` branch always lands — that half
|
|
55
|
+
* was fixed by #2309's fetch-before-append. This is the other half: whether
|
|
56
|
+
* the push that follows it did, and why not when it didn't, in one line a
|
|
57
|
+
* renderer can show directly (#2310).
|
|
58
|
+
*/
|
|
59
|
+
export interface PendingGatePush {
|
|
60
|
+
record: PendingGateRecord;
|
|
61
|
+
/**
|
|
62
|
+
* True when the push reached the remote. False when there was no remote
|
|
63
|
+
* configured, or the push was rejected — `pushWarning` says which.
|
|
64
|
+
*/
|
|
65
|
+
pushed: boolean;
|
|
66
|
+
/** Set when `pushed` is false. */
|
|
67
|
+
pushWarning?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
/** The gate ledger, as the two executors need it: read both kinds of line, append a pending fact. */
|
|
43
71
|
export interface GateLedgerPort {
|
|
44
72
|
read(op: string): Promise<{ resolutions: GateResolutionRecord[]; pending: PendingGateRecord[] }>;
|
|
45
|
-
appendPending(input: PendingGateInput): Promise<
|
|
73
|
+
appendPending(input: PendingGateInput): Promise<PendingGatePush>;
|
|
46
74
|
}
|
|
47
75
|
|
|
48
76
|
/**
|
|
49
|
-
* The real port: the `chant/lifecycle` orphan branch.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* a
|
|
77
|
+
* The real port: the `chant/lifecycle` orphan branch.
|
|
78
|
+
*
|
|
79
|
+
* Appending is always local-first and always lands (#2309's fetch-before-
|
|
80
|
+
* append). The push that follows is reported rather than swallowed (#2310):
|
|
81
|
+
* a pending fact that never reaches the remote is still a correct local
|
|
82
|
+
* answer — the run is right to gate, and does — but an operator elsewhere
|
|
83
|
+
* cannot approve a gate whose pending record they cannot see, so the caller
|
|
84
|
+
* gets `pushed: false` and a reason instead of silence. Mirrors the shape
|
|
85
|
+
* `chant approve` reports through (`../cli/handlers/operator.ts`'s
|
|
86
|
+
* `reportedPush`, #2309 review).
|
|
54
87
|
*/
|
|
55
88
|
export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
56
89
|
return {
|
|
57
90
|
async read(op) {
|
|
91
|
+
// The ledger branch has to be in the checkout before it is read
|
|
92
|
+
// (#2303): a CI clone fetches the pipeline's own ref and nothing else,
|
|
93
|
+
// and an unfetched branch reads as an empty ledger — which is
|
|
94
|
+
// indistinguishable from "nothing has been approved" and makes a
|
|
95
|
+
// retried job record a second pending fact for a gate that was already
|
|
96
|
+
// approved. Refuses rather than guessing when the fetch cannot settle
|
|
97
|
+
// it; see `requireLifecycleLedger`.
|
|
98
|
+
await requireLifecycleLedger(opts);
|
|
58
99
|
const { resolutions, pending } = await readGateLedger(op, opts);
|
|
59
100
|
return { resolutions, pending };
|
|
60
101
|
},
|
|
61
102
|
async appendPending(input) {
|
|
62
103
|
const { record } = await appendPendingGate(input, opts);
|
|
63
|
-
|
|
64
|
-
|
|
104
|
+
try {
|
|
105
|
+
const pushed = await pushLifecycle(opts);
|
|
106
|
+
return pushed
|
|
107
|
+
? { record, pushed }
|
|
108
|
+
: {
|
|
109
|
+
record,
|
|
110
|
+
pushed,
|
|
111
|
+
pushWarning:
|
|
112
|
+
"no remote is configured for chant/lifecycle — the pending fact was recorded locally only",
|
|
113
|
+
};
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return {
|
|
116
|
+
record,
|
|
117
|
+
pushed: false,
|
|
118
|
+
pushWarning: err instanceof Error ? err.message : String(err),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
65
121
|
},
|
|
66
122
|
};
|
|
67
123
|
}
|
|
@@ -82,7 +138,8 @@ export function memoryGateLedgerPort(
|
|
|
82
138
|
const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
|
|
83
139
|
pending.push(record);
|
|
84
140
|
appended.push(record);
|
|
85
|
-
|
|
141
|
+
// No remote in an in-memory ledger — there is nothing to fail to reach.
|
|
142
|
+
return { record, pushed: true };
|
|
86
143
|
},
|
|
87
144
|
};
|
|
88
145
|
}
|
|
@@ -98,14 +155,82 @@ export interface GateCheckInput {
|
|
|
98
155
|
timeout?: string;
|
|
99
156
|
/** Identifies the run that reached the gate, when the caller has one. */
|
|
100
157
|
runId?: string;
|
|
158
|
+
/**
|
|
159
|
+
* The plan this run reached the gate with (#2300) — `computePlanDigest`'s
|
|
160
|
+
* output (`../lifecycle/plan-digest.ts`), from the Plan phase that ran a
|
|
161
|
+
* moment ago.
|
|
162
|
+
*
|
|
163
|
+
* Supplying it makes the gate plan-bound: only a resolution recorded for
|
|
164
|
+
* this exact digest satisfies it, and one recorded for a different plan (or
|
|
165
|
+
* for no plan at all) is reported as a {@link GateDigestMismatch} instead.
|
|
166
|
+
* Omitting it keeps the pre-#2300 rule, where the newest resolution since
|
|
167
|
+
* the standing pending fact satisfies the gate whatever has changed since.
|
|
168
|
+
*/
|
|
169
|
+
planDigest?: string;
|
|
101
170
|
/** ISO-8601 "now" — supplied by the caller, so the decision is deterministic under test. */
|
|
102
171
|
now?: string;
|
|
103
172
|
}
|
|
104
173
|
|
|
174
|
+
/**
|
|
175
|
+
* A standing resolution that answers this gate but not this plan (#2300) —
|
|
176
|
+
* what a refusal names.
|
|
177
|
+
*/
|
|
178
|
+
export interface GateDigestMismatch {
|
|
179
|
+
/** The plan that was approved. `undefined` for a resolution written before #2300, which recorded no plan at all. */
|
|
180
|
+
approved?: string;
|
|
181
|
+
/** The plan this run's Plan phase produced. */
|
|
182
|
+
planned: string;
|
|
183
|
+
/** Who recorded the approval that does not apply here. */
|
|
184
|
+
resolvedBy: string;
|
|
185
|
+
/** When they recorded it. */
|
|
186
|
+
timestamp: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The refusal line for a {@link GateDigestMismatch}: which plan was approved,
|
|
191
|
+
* which was planned, and what closes the gap. One function so the executor's
|
|
192
|
+
* step record, the human render and the CI summary say the same thing.
|
|
193
|
+
*
|
|
194
|
+
* The two cases get different prose because they are different facts. A
|
|
195
|
+
* resolution for another plan means something changed between the approval
|
|
196
|
+
* and this run. A resolution with no plan on it means nothing is known to
|
|
197
|
+
* have changed — the record simply never said what it approved, which is
|
|
198
|
+
* every record written before #2300.
|
|
199
|
+
*/
|
|
200
|
+
export function describeGateMismatch(op: string, gate: string, mismatch: GateDigestMismatch): string {
|
|
201
|
+
const why =
|
|
202
|
+
mismatch.approved === undefined
|
|
203
|
+
? "That resolution predates plan-bound gates (#2300) and records no plan at all, so it cannot " +
|
|
204
|
+
"answer for this one. Approving again binds it:"
|
|
205
|
+
: "The configuration or the live system changed between that approval and this plan, so it needs " +
|
|
206
|
+
"a fresh one:";
|
|
207
|
+
return (
|
|
208
|
+
`Gate "${gate}" is approved, but not for this plan. ` +
|
|
209
|
+
`approved: ${describePlanDigest(mismatch.approved)} (by ${mismatch.resolvedBy} at ${mismatch.timestamp}); ` +
|
|
210
|
+
`planned: ${mismatch.planned}. ` +
|
|
211
|
+
`${why} ${approveCommand(op, gate)}`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
105
215
|
/** Either the gate is answered, or it is a standing fact. */
|
|
106
216
|
export type GateCheck =
|
|
107
217
|
| { satisfied: true; resolution: GateResolutionRecord }
|
|
108
|
-
| {
|
|
218
|
+
| {
|
|
219
|
+
satisfied: false;
|
|
220
|
+
pending: PendingGateRecord;
|
|
221
|
+
recorded: boolean;
|
|
222
|
+
/**
|
|
223
|
+
* Whether this run's own append reached the remote — set only when
|
|
224
|
+
* `recorded` is true. A gate left standing from an earlier run pushed
|
|
225
|
+
* (or didn't) on that run; this one wrote nothing, so it has nothing
|
|
226
|
+
* new to report (#2310).
|
|
227
|
+
*/
|
|
228
|
+
pushed?: boolean;
|
|
229
|
+
/** Set when `pushed` is false. */
|
|
230
|
+
pushWarning?: string;
|
|
231
|
+
/** Present when a resolution stands for this gate but for another plan (#2300). */
|
|
232
|
+
mismatch?: GateDigestMismatch;
|
|
233
|
+
};
|
|
109
234
|
|
|
110
235
|
/** The beginning of time — the anchor for a gate that has never been recorded pending, so any resolution for it counts. */
|
|
111
236
|
const EPOCH = new Date(0).toISOString();
|
|
@@ -114,28 +239,61 @@ const EPOCH = new Date(0).toISOString();
|
|
|
114
239
|
* Decide a gate against the ledger, recording the pending fact when it isn't
|
|
115
240
|
* answered.
|
|
116
241
|
*
|
|
117
|
-
* - A resolution
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* -
|
|
122
|
-
*
|
|
123
|
-
*
|
|
242
|
+
* - A resolution recorded for this run's own plan, newer than the newest
|
|
243
|
+
* pending fact, satisfies the gate. On a gate that binds no plan
|
|
244
|
+
* (`input.planDigest` absent) the plan half of that drops out and the rule
|
|
245
|
+
* is the pre-#2300 one: any resolution newer than the pending fact.
|
|
246
|
+
* - A resolution that stands for the gate but for a different plan does not
|
|
247
|
+
* satisfy it (#2300). The run is gated, `mismatch` names both plans, and a
|
|
248
|
+
* fresh pending fact is recorded for the plan that actually ran — so
|
|
249
|
+
* `chant approve` has something current to approve and the loop closes in
|
|
250
|
+
* one more command rather than needing the digest typed out.
|
|
251
|
+
* - Otherwise, a pending fact that hasn't expired *and was recorded for this
|
|
252
|
+
* same plan* stands as it is: the run ends `gated` and the ledger is left
|
|
253
|
+
* alone, so an operator ticking every minute against an unapproved gate
|
|
254
|
+
* does not append a line a minute. A pending fact for a different plan is
|
|
255
|
+
* stale in the way that matters and is replaced.
|
|
256
|
+
* - Otherwise (no pending fact, or the newest one has expired, or it is for
|
|
257
|
+
* another plan) a fresh pending fact is appended and the run ends `gated`.
|
|
258
|
+
* `recorded` says which of the two happened, so a renderer can tell "just
|
|
259
|
+
* recorded" from "still standing".
|
|
124
260
|
*/
|
|
125
261
|
export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput): Promise<GateCheck> {
|
|
126
262
|
const now = input.now ?? new Date().toISOString();
|
|
127
263
|
const { resolutions, pending } = await port.read(input.op);
|
|
128
264
|
|
|
129
265
|
const standing = latestPendingGate(pending, input.gate);
|
|
130
|
-
const resolution
|
|
266
|
+
const { resolution, mismatched } = latestResolutionForPlan(
|
|
267
|
+
resolutions,
|
|
268
|
+
input.gate,
|
|
269
|
+
standing?.timestamp ?? EPOCH,
|
|
270
|
+
input.planDigest,
|
|
271
|
+
);
|
|
131
272
|
if (resolution) return { satisfied: true, resolution };
|
|
132
273
|
|
|
133
|
-
|
|
134
|
-
|
|
274
|
+
// `input.planDigest` is defined whenever `mismatched` is — `latestResolutionForPlan`
|
|
275
|
+
// returns a mismatch only on the plan-bound path.
|
|
276
|
+
const mismatch: GateDigestMismatch | undefined = mismatched
|
|
277
|
+
? {
|
|
278
|
+
...(mismatched.planDigest !== undefined ? { approved: mismatched.planDigest } : {}),
|
|
279
|
+
planned: input.planDigest!,
|
|
280
|
+
resolvedBy: mismatched.resolvedBy,
|
|
281
|
+
timestamp: mismatched.timestamp,
|
|
282
|
+
}
|
|
283
|
+
: undefined;
|
|
284
|
+
const asMismatch = mismatch ? { mismatch } : {};
|
|
285
|
+
|
|
286
|
+
// A standing fact only stands for the plan it was recorded against. When
|
|
287
|
+
// the plan has moved, re-recording is what gives `chant approve` (which
|
|
288
|
+
// defaults to the newest pending fact's digest) the current plan to
|
|
289
|
+
// approve; leaving the old fact standing would make the common path
|
|
290
|
+
// approve a plan that is no longer the one being run.
|
|
291
|
+
if (standing && !isPendingGateExpired(standing, now) && standing.planDigest === input.planDigest) {
|
|
292
|
+
return { satisfied: false, pending: standing, recorded: false, ...asMismatch };
|
|
135
293
|
}
|
|
136
294
|
|
|
137
295
|
const url = resolveApprovalUrl();
|
|
138
|
-
const record = await port.appendPending({
|
|
296
|
+
const { record, pushed, pushWarning } = await port.appendPending({
|
|
139
297
|
op: input.op,
|
|
140
298
|
gate: input.gate,
|
|
141
299
|
timestamp: now,
|
|
@@ -145,8 +303,16 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
145
303
|
...(input.description ? { description: input.description } : {}),
|
|
146
304
|
...(input.runId ? { runId: input.runId } : {}),
|
|
147
305
|
...(url ? { url } : {}),
|
|
306
|
+
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
148
307
|
});
|
|
149
|
-
return {
|
|
308
|
+
return {
|
|
309
|
+
satisfied: false,
|
|
310
|
+
pending: record,
|
|
311
|
+
recorded: true,
|
|
312
|
+
pushed,
|
|
313
|
+
...(pushWarning ? { pushWarning } : {}),
|
|
314
|
+
...asMismatch,
|
|
315
|
+
};
|
|
150
316
|
}
|
|
151
317
|
|
|
152
318
|
/** The line every renderer prints to say how a pending gate is cleared. */
|
package/src/op/index.ts
CHANGED
|
@@ -50,8 +50,11 @@ export type { ActivityProfile, ActivityProfileName } from "./activity-profiles";
|
|
|
50
50
|
export { NonRetryableActivityError, nonRetryableFailure } from "./activity-failure";
|
|
51
51
|
export { runOpLocally, parseDuration, OpRunFailure } from "./local-executor";
|
|
52
52
|
export type { StepRecord, OpRunResult, RunOpOptions } from "./local-executor";
|
|
53
|
-
export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand } from "./gate";
|
|
54
|
-
export type { GateLedgerPort, GateCheck, GateCheckInput } from "./gate";
|
|
53
|
+
export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch } from "./gate";
|
|
54
|
+
export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch } from "./gate";
|
|
55
|
+
export {
|
|
56
|
+
computePlanDigest, isPlanDigest, describePlanDigest, PLAN_DIGEST_ALGORITHM,
|
|
57
|
+
} from "../lifecycle/plan-digest";
|
|
55
58
|
export { gateName, usesDeprecatedGateKey, DEPRECATED_GATE_KEY_WARNING } from "./gate-name";
|
|
56
59
|
export type { GateNamed } from "./gate-name";
|
|
57
60
|
export { createLocalOpRuntime } from "./runtimes/local";
|
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
parseDuration,
|
|
7
7
|
OpRunFailure,
|
|
8
8
|
} from "./local-executor";
|
|
9
|
-
import { memoryGateLedgerPort } from "./gate";
|
|
10
|
-
import
|
|
9
|
+
import { memoryGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
10
|
+
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
11
|
+
import { renderHuman } from "./local-output";
|
|
12
|
+
import type { GateResolutionRecord, PendingGateRecord, PendingGateInput } from "../lifecycle/gate-ledger";
|
|
11
13
|
import { stepOutput } from "./step-output-ref";
|
|
12
14
|
|
|
13
15
|
// Fast profiles so retry/timeout tests run in milliseconds.
|
|
@@ -324,6 +326,33 @@ describe("runOpLocally — gate as fact (#2119)", () => {
|
|
|
324
326
|
]);
|
|
325
327
|
});
|
|
326
328
|
|
|
329
|
+
// #2310: the gate still ends the run `gated` — an append that only reaches
|
|
330
|
+
// the local branch is still a correct local answer, and the #2309 author
|
|
331
|
+
// was right that this must not become a failure. But the run used to say
|
|
332
|
+
// nothing at all about the rejected push; now it does.
|
|
333
|
+
test("a pending fact whose push was rejected still ends the run gated, and says the push failed", async () => {
|
|
334
|
+
const { calls, activities } = tracked();
|
|
335
|
+
const port = {
|
|
336
|
+
async read() {
|
|
337
|
+
return { resolutions: [] as GateResolutionRecord[], pending: [] as PendingGateRecord[] };
|
|
338
|
+
},
|
|
339
|
+
async appendPending(input: PendingGateInput) {
|
|
340
|
+
return {
|
|
341
|
+
record: { version: 1 as const, kind: "pending" as const, ...input },
|
|
342
|
+
pushed: false,
|
|
343
|
+
pushWarning: "chant/lifecycle remote branch has moved since this run started",
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
const result = await runOpLocally(gatedOp(), activities, PROFILES, undefined, { gates: port, now: NOW });
|
|
348
|
+
|
|
349
|
+
expect(result.status).toBe("gated");
|
|
350
|
+
expect(calls).toEqual(["before"]);
|
|
351
|
+
expect(result.gate).toMatchObject({ op: "test-op", gate: "approve-prod" });
|
|
352
|
+
expect(result.gatePushed).toBe(false);
|
|
353
|
+
expect(result.gatePushWarning).toBe("chant/lifecycle remote branch has moved since this run started");
|
|
354
|
+
});
|
|
355
|
+
|
|
327
356
|
// #2202: the gate step's name key is `gate`; `signalName` is read through
|
|
328
357
|
// 0.59.0, so a step still spelling it that way reaches the same ledger entry.
|
|
329
358
|
test("a gate step still using the deprecated `signalName` key names the same gate", async () => {
|
|
@@ -497,3 +526,312 @@ describe("runOpLocally — step-output references (#1290)", () => {
|
|
|
497
526
|
expect(result.records[1].args).toEqual({ stacks: ["stack-a"] });
|
|
498
527
|
});
|
|
499
528
|
});
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* #2301 deliverable 2. The reported symptom was a whole apply that printed
|
|
532
|
+
* `Op "live-apply" failed after 43.8s` and nothing else — no error line, no
|
|
533
|
+
* failing step — because the gate's ledger write threw a plain `Error` and
|
|
534
|
+
* the executor's catch only ever unpacked `PhaseFailure` and `GateStop`.
|
|
535
|
+
* Everything else was built into an `OpRunFailure` and never read again.
|
|
536
|
+
*
|
|
537
|
+
* These assert the two halves separately: the gate step now produces a
|
|
538
|
+
* failing record like any other step, and the outer catch no longer drops an
|
|
539
|
+
* error it does not recognise. The second is the general one — the gate is
|
|
540
|
+
* where it was found, not the only place it bites.
|
|
541
|
+
*/
|
|
542
|
+
describe("runOpLocally — a failure inside the run says what it was (#2301)", () => {
|
|
543
|
+
const NOW = "2026-09-05T12:00:00.000Z";
|
|
544
|
+
const LEDGER_ERROR =
|
|
545
|
+
"cannot write _gates/live-apply.jsonl on the chant/lifecycle branch: " +
|
|
546
|
+
"git commit-tree failed: fatal: unable to auto-detect email address (got 'runner@runner.(none)')";
|
|
547
|
+
|
|
548
|
+
/** A gate ledger whose append fails the way a CI checkout with no git identity does. */
|
|
549
|
+
function unwritablePort(message = LEDGER_ERROR) {
|
|
550
|
+
return {
|
|
551
|
+
async read() {
|
|
552
|
+
return { resolutions: [] as GateResolutionRecord[], pending: [] as PendingGateRecord[] };
|
|
553
|
+
},
|
|
554
|
+
async appendPending(): Promise<never> {
|
|
555
|
+
throw new Error(message);
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function gateOnly(): OpConfig {
|
|
561
|
+
return op({
|
|
562
|
+
phases: [
|
|
563
|
+
{ name: "Plan", steps: [{ kind: "activity", fn: "plan" }] },
|
|
564
|
+
{ name: "Gate", steps: [{ kind: "gate", gate: "approve-live-apply" }] },
|
|
565
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "apply" }] },
|
|
566
|
+
],
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const noopActivities = new Map<string, ActivityFn>([
|
|
571
|
+
["plan", async () => undefined],
|
|
572
|
+
["apply", async () => undefined],
|
|
573
|
+
]);
|
|
574
|
+
|
|
575
|
+
test("a gate whose ledger write fails is a failing step carrying the cause", async () => {
|
|
576
|
+
const err = await runOpLocally(gateOnly(), noopActivities, PROFILES, undefined, {
|
|
577
|
+
gates: unwritablePort(),
|
|
578
|
+
now: NOW,
|
|
579
|
+
}).then(() => undefined, (e: unknown) => e);
|
|
580
|
+
|
|
581
|
+
expect(err).toBeInstanceOf(OpRunFailure);
|
|
582
|
+
const { result } = err as OpRunFailure;
|
|
583
|
+
expect(result.status).toBe("fail");
|
|
584
|
+
|
|
585
|
+
const gateRecord = result.records.find((r) => r.fn === "gate:approve-live-apply");
|
|
586
|
+
expect(gateRecord?.status).toBe("fail");
|
|
587
|
+
expect(gateRecord?.error).toBe(LEDGER_ERROR);
|
|
588
|
+
|
|
589
|
+
// The run stops at the gate, exactly as a failing activity stops its
|
|
590
|
+
// phase — the Apply phase never starts.
|
|
591
|
+
expect(result.records.map((r) => [r.fn, r.status])).toEqual([
|
|
592
|
+
["plan", "ok"],
|
|
593
|
+
["gate:approve-live-apply", "fail"],
|
|
594
|
+
]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("the rendered output names the cause instead of a bare failure line", async () => {
|
|
598
|
+
const err = await runOpLocally(gateOnly(), noopActivities, PROFILES, undefined, {
|
|
599
|
+
gates: unwritablePort(),
|
|
600
|
+
now: NOW,
|
|
601
|
+
}).then(() => undefined, (e: unknown) => e);
|
|
602
|
+
|
|
603
|
+
const lines: string[] = [];
|
|
604
|
+
renderHuman((err as OpRunFailure).result, (line) => lines.push(line));
|
|
605
|
+
|
|
606
|
+
expect(lines).toContain(` ${LEDGER_ERROR}`);
|
|
607
|
+
expect(lines.at(-1)).toMatch(/^Op "test-op" failed after /);
|
|
608
|
+
// The whole point: the failing step is visible, so the last line is no
|
|
609
|
+
// longer the only line about the failure.
|
|
610
|
+
expect(lines.some((l) => l.startsWith(" ✗ gate:approve-live-apply"))).toBe(true);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("an error the executor does not recognise is recorded rather than dropped", async () => {
|
|
614
|
+
// Neither a step's own failure nor a gate: the caller's progress callback
|
|
615
|
+
// throws, which unwinds the phase loop as a plain `Error`. Before #2301
|
|
616
|
+
// that produced an `OpRunFailure` whose `records` held nothing marked
|
|
617
|
+
// failed and nothing carrying a message — a run that failed and said
|
|
618
|
+
// nothing at all about why. The gate is where this was found, not the
|
|
619
|
+
// only thing it swallowed.
|
|
620
|
+
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "plan" }] }] });
|
|
621
|
+
|
|
622
|
+
const err = await runOpLocally(config, noopActivities, PROFILES, undefined, {
|
|
623
|
+
onRecord: () => {
|
|
624
|
+
throw new Error("progress sink exploded");
|
|
625
|
+
},
|
|
626
|
+
}).then(() => undefined, (e: unknown) => e);
|
|
627
|
+
|
|
628
|
+
expect(err).toBeInstanceOf(OpRunFailure);
|
|
629
|
+
const failure = err as OpRunFailure;
|
|
630
|
+
expect(failure.result.records).toHaveLength(1);
|
|
631
|
+
expect(failure.result.records[0]).toMatchObject({
|
|
632
|
+
status: "fail",
|
|
633
|
+
error: expect.stringContaining("progress sink exploded"),
|
|
634
|
+
});
|
|
635
|
+
// The original error object is still reachable for a programmatic caller.
|
|
636
|
+
expect((failure.cause as Error).message).toMatch(/progress sink exploded/);
|
|
637
|
+
|
|
638
|
+
const lines: string[] = [];
|
|
639
|
+
renderHuman(failure.result, (line) => lines.push(line));
|
|
640
|
+
expect(lines.some((l) => l.includes("progress sink exploded"))).toBe(true);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* #2300, measured on INTENTIUS/choudoufu#1026.
|
|
646
|
+
*
|
|
647
|
+
* The exact sequence that issue ran against a floci-backed root: `chant run
|
|
648
|
+
* live-apply` returns `gated`, `chant approve` writes the resolution, a
|
|
649
|
+
* resource in the root is renamed, and `chant run live-apply` runs again. It
|
|
650
|
+
* applied, with no refusal, because the resolution carried the op, the gate,
|
|
651
|
+
* the approver and a timestamp — and nothing about the plan. The approval had
|
|
652
|
+
* authorised the next run rather than the plan its approver read.
|
|
653
|
+
*
|
|
654
|
+
* These drive the same four steps through the executor, with a Plan phase
|
|
655
|
+
* whose digest moves when the root does.
|
|
656
|
+
*/
|
|
657
|
+
describe("runOpLocally — a gate approves a plan, not the next run (#2300)", () => {
|
|
658
|
+
const NOW = "2026-09-09T12:00:00.000Z";
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* A gate ledger a test can approve against, which `memoryGateLedgerPort`
|
|
662
|
+
* cannot: `chant approve` appends a resolution *between* two runs, and the
|
|
663
|
+
* shared port has to carry it into the second one.
|
|
664
|
+
*/
|
|
665
|
+
function approvableLedger(): GateLedgerPort & {
|
|
666
|
+
approve(gate: string, planDigest: string | undefined, at: string): void;
|
|
667
|
+
pending: PendingGateRecord[];
|
|
668
|
+
} {
|
|
669
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
670
|
+
const pending: PendingGateRecord[] = [];
|
|
671
|
+
return {
|
|
672
|
+
pending,
|
|
673
|
+
approve(gate, planDigest, at) {
|
|
674
|
+
resolutions.push({
|
|
675
|
+
version: 1, op: "live-apply", gate, resolvedBy: "alex", timestamp: at,
|
|
676
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
677
|
+
});
|
|
678
|
+
},
|
|
679
|
+
async read() {
|
|
680
|
+
return { resolutions: [...resolutions], pending: [...pending] };
|
|
681
|
+
},
|
|
682
|
+
async appendPending(input) {
|
|
683
|
+
const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
|
|
684
|
+
pending.push(record);
|
|
685
|
+
// No remote in an in-memory ledger — nothing to fail to reach (#2310).
|
|
686
|
+
return { record, pushed: true };
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The root under test, and the Op over it. `terraformPlan` digests whatever
|
|
693
|
+
* `root.address` currently is, so renaming a resource between runs is one
|
|
694
|
+
* assignment — the test's stand-in for editing the `.tf` file.
|
|
695
|
+
*/
|
|
696
|
+
function harness() {
|
|
697
|
+
const root = { address: "aws_s3_bucket.original" };
|
|
698
|
+
const applied: string[] = [];
|
|
699
|
+
const activities = new Map<string, ActivityFn>([
|
|
700
|
+
["terraformPlan", async () => ({
|
|
701
|
+
planFile: "chant.tfplan",
|
|
702
|
+
planDigest: computePlanDigest("terraform-plan", { resourceChanges: [{ address: root.address }] }),
|
|
703
|
+
})],
|
|
704
|
+
["terraformApply", async (args) => {
|
|
705
|
+
applied.push(String(args.planFile));
|
|
706
|
+
return { applied: true };
|
|
707
|
+
}],
|
|
708
|
+
]);
|
|
709
|
+
const config = op({
|
|
710
|
+
name: "live-apply",
|
|
711
|
+
phases: [
|
|
712
|
+
{ name: "Plan", steps: [{ kind: "activity", fn: "terraformPlan", id: "plan", args: { root: "app" } }] },
|
|
713
|
+
{
|
|
714
|
+
name: "Gate",
|
|
715
|
+
steps: [{ kind: "gate", gate: "approve-live-apply", plan: stepOutput("plan", "planDigest") }],
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
name: "Apply",
|
|
719
|
+
steps: [{ kind: "activity", fn: "terraformApply", args: { planFile: stepOutput("plan", "planFile") } }],
|
|
720
|
+
},
|
|
721
|
+
],
|
|
722
|
+
});
|
|
723
|
+
const digestOf = (address: string) =>
|
|
724
|
+
computePlanDigest("terraform-plan", { resourceChanges: [{ address }] });
|
|
725
|
+
return { root, applied, activities, config, digestOf };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
test("approve, rename a resource in the root, re-run — the Apply phase refuses, naming both digests", async () => {
|
|
729
|
+
const { root, applied, activities, config, digestOf } = harness();
|
|
730
|
+
const gates = approvableLedger();
|
|
731
|
+
|
|
732
|
+
// 1. `chant run live-apply` — gated, and the pending fact names the plan.
|
|
733
|
+
const first = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW });
|
|
734
|
+
expect(first.status).toBe("gated");
|
|
735
|
+
expect(first.gate?.planDigest).toBe(digestOf("aws_s3_bucket.original"));
|
|
736
|
+
expect(applied).toEqual([]);
|
|
737
|
+
|
|
738
|
+
// 2. `chant approve live-apply approve-live-apply` — for the plan above.
|
|
739
|
+
gates.approve("approve-live-apply", digestOf("aws_s3_bucket.original"), "2026-09-09T12:05:00.000Z");
|
|
740
|
+
|
|
741
|
+
// 3. A resource in the root is renamed.
|
|
742
|
+
root.address = "aws_s3_bucket.renamed";
|
|
743
|
+
|
|
744
|
+
// 4. `chant run live-apply` again. On the pre-#2300 rule this applied.
|
|
745
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, {
|
|
746
|
+
gates, now: "2026-09-09T12:10:00.000Z",
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
expect(second.status).toBe("gated");
|
|
750
|
+
expect(applied).toEqual([]);
|
|
751
|
+
|
|
752
|
+
const gateRecord = second.records.find((r) => r.fn === "gate:approve-live-apply");
|
|
753
|
+
expect(gateRecord?.refusal).toContain(`approved: ${digestOf("aws_s3_bucket.original")}`);
|
|
754
|
+
expect(gateRecord?.refusal).toContain(`planned: ${digestOf("aws_s3_bucket.renamed")}`);
|
|
755
|
+
expect(gateRecord?.refusal).toContain("chant approve live-apply approve-live-apply");
|
|
756
|
+
expect(gateRecord?.refusal).toContain("changed between that approval and this plan");
|
|
757
|
+
// The Apply step never ran, and says so.
|
|
758
|
+
expect(second.records.find((r) => r.fn === "terraformApply")?.status).toBe("skipped");
|
|
759
|
+
|
|
760
|
+
// The refusal reaches a reader, not just the record.
|
|
761
|
+
const lines: string[] = [];
|
|
762
|
+
renderHuman(second, (line) => lines.push(line));
|
|
763
|
+
expect(lines.some((l) => l.includes("[refused]") && l.includes("not for this plan"))).toBe(true);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("approve, re-run with the root unchanged — it applies", async () => {
|
|
767
|
+
const { applied, activities, config, digestOf } = harness();
|
|
768
|
+
const gates = approvableLedger();
|
|
769
|
+
|
|
770
|
+
expect((await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW })).status).toBe("gated");
|
|
771
|
+
gates.approve("approve-live-apply", digestOf("aws_s3_bucket.original"), "2026-09-09T12:05:00.000Z");
|
|
772
|
+
|
|
773
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, {
|
|
774
|
+
gates, now: "2026-09-09T12:10:00.000Z",
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
expect(second.status).toBe("ok");
|
|
778
|
+
expect(applied).toEqual(["chant.tfplan"]);
|
|
779
|
+
expect(second.records.find((r) => r.fn === "gate:approve-live-apply")?.approval?.resolvedBy).toBe("alex");
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The migration (#2300). Every resolution written before this change has no
|
|
784
|
+
* `planDigest` at all, and the safe reading is that it matches nothing: such
|
|
785
|
+
* a record proves someone approved something and nothing about what.
|
|
786
|
+
* Accepting it once — the other candidate reading — would apply exactly the
|
|
787
|
+
* unreviewed change this check exists to catch, on the estates that have
|
|
788
|
+
* been running longest.
|
|
789
|
+
*/
|
|
790
|
+
test("a resolution recorded before plan-bound gates does not pass, and the refusal says so", async () => {
|
|
791
|
+
const { applied, activities, config, digestOf } = harness();
|
|
792
|
+
const gates = approvableLedger();
|
|
793
|
+
|
|
794
|
+
expect((await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW })).status).toBe("gated");
|
|
795
|
+
// A pre-#2300 resolution: newer than the pending fact, no plan on it.
|
|
796
|
+
gates.approve("approve-live-apply", undefined, "2026-09-09T12:05:00.000Z");
|
|
797
|
+
|
|
798
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, {
|
|
799
|
+
gates, now: "2026-09-09T12:10:00.000Z",
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
expect(second.status).toBe("gated");
|
|
803
|
+
expect(applied).toEqual([]);
|
|
804
|
+
const gateRecord = second.records.find((r) => r.fn === "gate:approve-live-apply");
|
|
805
|
+
expect(gateRecord?.refusal).toContain("approved: (none — recorded before plan-bound gates)");
|
|
806
|
+
expect(gateRecord?.refusal).toContain(`planned: ${digestOf("aws_s3_bucket.original")}`);
|
|
807
|
+
// ...and says why, which is not "something changed": nothing is known to
|
|
808
|
+
// have changed, the record just never said what it approved.
|
|
809
|
+
expect(gateRecord?.refusal).toContain("records no plan at all");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* A gate with no `plan` is every gate that existed before #2300 — a
|
|
814
|
+
* component gate, an authored `gate()` with nothing to bind. Those decide
|
|
815
|
+
* exactly as they did: newest resolution since the pending fact wins.
|
|
816
|
+
*/
|
|
817
|
+
test("a gate that binds no plan is unchanged", async () => {
|
|
818
|
+
const activities = new Map<string, ActivityFn>([["deploy", async () => ({ ok: true })]]);
|
|
819
|
+
const config = op({
|
|
820
|
+
name: "live-apply",
|
|
821
|
+
phases: [
|
|
822
|
+
{ name: "Gate", steps: [{ kind: "gate", gate: "approve-live-apply" }] },
|
|
823
|
+
{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy" }] },
|
|
824
|
+
],
|
|
825
|
+
});
|
|
826
|
+
const gates = approvableLedger();
|
|
827
|
+
|
|
828
|
+
expect((await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW })).status).toBe("gated");
|
|
829
|
+
expect(gates.pending[0].planDigest).toBeUndefined();
|
|
830
|
+
gates.approve("approve-live-apply", undefined, "2026-09-09T12:05:00.000Z");
|
|
831
|
+
|
|
832
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, {
|
|
833
|
+
gates, now: "2026-09-09T12:10:00.000Z",
|
|
834
|
+
});
|
|
835
|
+
expect(second.status).toBe("ok");
|
|
836
|
+
});
|
|
837
|
+
});
|