@lovrozagar/oat 0.7.3 → 0.7.5
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/README.md +181 -136
- package/dist/cli.js +19 -1
- package/dist/config/define-config.d.ts +29 -4
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js.map +1 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +1 -0
- package/dist/report/console.js.map +1 -1
- package/dist/report/matrix.d.ts.map +1 -1
- package/dist/report/matrix.js +4 -1
- package/dist/report/matrix.js.map +1 -1
- package/dist/report/render.d.ts +4 -0
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +12 -2
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/body.d.ts.map +1 -1
- package/dist/runtime/body.js +18 -2
- package/dist/runtime/body.js.map +1 -1
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +199 -123
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +7 -1
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +3 -1
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/effects.d.ts +43 -0
- package/dist/runtime/effects.d.ts.map +1 -0
- package/dist/runtime/effects.js +141 -0
- package/dist/runtime/effects.js.map +1 -0
- package/dist/runtime/exchanges.d.ts +37 -0
- package/dist/runtime/exchanges.d.ts.map +1 -0
- package/dist/runtime/exchanges.js +298 -0
- package/dist/runtime/exchanges.js.map +1 -0
- package/dist/runtime/finding.d.ts +6 -4
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js +13 -8
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/run.d.ts +9 -0
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +17 -1
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/upload-each.d.ts +14 -0
- package/dist/runtime/upload-each.d.ts.map +1 -0
- package/dist/runtime/upload-each.js +69 -0
- package/dist/runtime/upload-each.js.map +1 -0
- package/dist/runtime/upload.d.ts +8 -0
- package/dist/runtime/upload.d.ts.map +1 -1
- package/dist/runtime/upload.js +26 -4
- package/dist/runtime/upload.js.map +1 -1
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +57 -27
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/extensions.d.ts +4 -1
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +8 -2
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.js +1 -1
- package/dist/spec/graph.js.map +1 -1
- package/package.json +1 -1
package/dist/runtime/checks.js
CHANGED
|
@@ -15,8 +15,10 @@ import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSc
|
|
|
15
15
|
import { driveAsync, inspectStreamAsync, matchesPredicate, resolveAsyncId } from "./async.js";
|
|
16
16
|
import { resolveOutOfBandValue } from "./auth.js";
|
|
17
17
|
import { buildCohort } from "./fixture.js";
|
|
18
|
+
import { forEachInvocation } from "./upload-each.js";
|
|
18
19
|
import { driveWait } from "./wait.js";
|
|
19
20
|
import { fillPath } from "./world.js";
|
|
21
|
+
import { bindAfterCreateEffects, bindCreatedScope, bindMissingPathParams, canFillPath, describeEffectHold, effectHolds, identityPathParam, } from "./effects.js";
|
|
20
22
|
/* ------------------------------------------------------------------- helpers */
|
|
21
23
|
/**
|
|
22
24
|
* A documented feature-gate 403 is not a 2xx-check failure and not SECURITY.
|
|
@@ -3170,13 +3172,18 @@ function bodyForOp(ctx, op) {
|
|
|
3170
3172
|
body[spec.granteeField] = delegate.inviteAs;
|
|
3171
3173
|
return body;
|
|
3172
3174
|
}
|
|
3175
|
+
function subject(entity, operationId, fixture) {
|
|
3176
|
+
if (fixture === undefined)
|
|
3177
|
+
return entity;
|
|
3178
|
+
return `${operationId} · ${fixture}`;
|
|
3179
|
+
}
|
|
3173
3180
|
function requestSchemaOf(ctx, op) {
|
|
3174
3181
|
const raw = ctx.model.rawOperations.get(op.operationId);
|
|
3175
3182
|
const picked = raw === undefined ? null : requestContent(raw);
|
|
3176
3183
|
return picked === null ? null : picked.schema;
|
|
3177
3184
|
}
|
|
3178
|
-
async function encodeOpBody(ctx, op, fields, variant = "baseline", index = 0) {
|
|
3179
|
-
const encoded = await encodeForOperation(op, ctx.model, fields,
|
|
3185
|
+
async function encodeOpBody(ctx, op, fields, variant = "baseline", index = 0, uploads = ctx.uploads) {
|
|
3186
|
+
const encoded = await encodeForOperation(op, ctx.model, fields, uploads, variant, index);
|
|
3180
3187
|
return encoded.contentType === undefined
|
|
3181
3188
|
? { body: encoded.body }
|
|
3182
3189
|
: { body: encoded.body, contentType: encoded.contentType };
|
|
@@ -3259,11 +3266,15 @@ function findConstrained(schema, predicate) {
|
|
|
3259
3266
|
* downstream consequence.
|
|
3260
3267
|
*/
|
|
3261
3268
|
/**
|
|
3262
|
-
* An operation declaring `x-effects` must produce
|
|
3269
|
+
* An operation declaring `x-effects` must produce the stated change.
|
|
3270
|
+
*
|
|
3271
|
+
* `count` is an exact cardinality delta (default 1). `min` is at-least. After a `create` on A,
|
|
3272
|
+
* later items in the same array fill a child list under A with the new id — from the write
|
|
3273
|
+
* response (`table_id` or the entity identity) or from A's list delta.
|
|
3263
3274
|
*
|
|
3264
3275
|
* `x-invalidate` says a read route changes; `x-effects` says *how*. That difference is what
|
|
3265
3276
|
* separates "something differed" — which is satisfied by a stray timestamp and missed by a
|
|
3266
|
-
* cache-stale read — from an
|
|
3277
|
+
* cache-stale read — from an assertion on cardinality and membership.
|
|
3267
3278
|
*/
|
|
3268
3279
|
/**
|
|
3269
3280
|
* A numeric field must compare numerically, not lexically.
|
|
@@ -3471,62 +3482,108 @@ const declaredEffectsOccur = {
|
|
|
3471
3482
|
needs: "an operation declaring x-effects",
|
|
3472
3483
|
async run(ctx) {
|
|
3473
3484
|
for (const op of ctx.effectOps) {
|
|
3474
|
-
|
|
3475
|
-
const
|
|
3476
|
-
const
|
|
3477
|
-
if (
|
|
3478
|
-
|
|
3479
|
-
|
|
3485
|
+
await forEachInvocation(op.operationId, ctx.uploads, async (uploads, slot) => {
|
|
3486
|
+
const fixture = slot?.filename;
|
|
3487
|
+
const effects = op.effects;
|
|
3488
|
+
if (effects.length === 0)
|
|
3489
|
+
return;
|
|
3490
|
+
const scope = { ...ctx.scope };
|
|
3491
|
+
const befores = new Map();
|
|
3492
|
+
for (const effect of effects) {
|
|
3493
|
+
const listOp = listOpFor(ctx, effect.entity);
|
|
3494
|
+
if (listOp === undefined)
|
|
3495
|
+
continue;
|
|
3496
|
+
if (befores.has(effect.entity))
|
|
3497
|
+
continue;
|
|
3498
|
+
befores.set(effect.entity, await observe(ctx, listOp, scope));
|
|
3480
3499
|
}
|
|
3481
|
-
const listOp = ctx.model.byOperationId.get(listId);
|
|
3482
|
-
if (listOp === undefined)
|
|
3483
|
-
continue;
|
|
3484
|
-
const before = await observe(ctx, listOp);
|
|
3485
|
-
if (before === null)
|
|
3486
|
-
continue;
|
|
3487
3500
|
const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
|
|
3488
3501
|
const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3489
3502
|
headers: ctx.auth(),
|
|
3490
|
-
|
|
3503
|
+
operationId: op.operationId,
|
|
3504
|
+
...(fixture === undefined ? {} : { fixture }),
|
|
3505
|
+
...(body === undefined ? {} : await encodeOpBody(ctx, op, body, "baseline", 0, uploads)),
|
|
3491
3506
|
});
|
|
3492
3507
|
if (standDownForFeatureGate(ctx, op, invoked, this.id))
|
|
3493
|
-
|
|
3508
|
+
return;
|
|
3494
3509
|
if (standDownForRateLimit(ctx, invoked, this.id))
|
|
3495
|
-
|
|
3510
|
+
return;
|
|
3496
3511
|
if (invoked.status >= 400) {
|
|
3497
|
-
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared effects are unverified
|
|
3498
|
-
|
|
3512
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared effects are unverified`, fixture);
|
|
3513
|
+
return;
|
|
3499
3514
|
}
|
|
3500
|
-
const
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
const delta = after.ids.length - before.ids.length;
|
|
3505
|
-
const added = after.ids.filter((id) => !before.ids.includes(id));
|
|
3506
|
-
const removed = before.ids.filter((id) => !after.ids.includes(id));
|
|
3507
|
-
const wanted = effect.op === "create" || effect.op === "append" ? expected : effect.op === "delete" ? -expected : 0;
|
|
3508
|
-
if (effect.op === "create" || effect.op === "append") {
|
|
3509
|
-
if (delta === wanted && added.length === expected)
|
|
3515
|
+
const deltas = new Map();
|
|
3516
|
+
Object.assign(scope, bindAfterCreateEffects(ctx.model, effects, invoked.responseBody, deltas));
|
|
3517
|
+
for (const effect of effects) {
|
|
3518
|
+
if (effect.op !== "create")
|
|
3510
3519
|
continue;
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
`(${added.length} added, ${removed.length} removed). A declared effect that does ` +
|
|
3514
|
-
"not occur means callers cannot rely on the operation having done anything.", [invoked, after.exchange]);
|
|
3515
|
-
continue;
|
|
3516
|
-
}
|
|
3517
|
-
if (effect.op === "delete") {
|
|
3518
|
-
if (delta === wanted && removed.length === expected)
|
|
3520
|
+
const param = identityPathParam(ctx.model, effect.entity);
|
|
3521
|
+
if (scope[param] !== undefined)
|
|
3519
3522
|
continue;
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
+
const listOp = listOpFor(ctx, effect.entity);
|
|
3524
|
+
if (listOp === undefined)
|
|
3525
|
+
continue;
|
|
3526
|
+
const afterParent = await observe(ctx, listOp, scope);
|
|
3527
|
+
if (afterParent.status !== "ok")
|
|
3528
|
+
continue;
|
|
3529
|
+
const before = befores.get(effect.entity);
|
|
3530
|
+
const prior = before?.status === "ok" ? before.ids : [];
|
|
3531
|
+
const added = afterParent.ids.filter((id) => !prior.includes(id));
|
|
3532
|
+
deltas.set(effect.entity, added);
|
|
3533
|
+
Object.assign(scope, bindCreatedScope(ctx.model, effect.entity, invoked.responseBody, added));
|
|
3523
3534
|
}
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3535
|
+
for (const effect of effects) {
|
|
3536
|
+
const listOp = listOpFor(ctx, effect.entity);
|
|
3537
|
+
if (listOp === undefined) {
|
|
3538
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} declares an effect on "${effect.entity}", which has no list route`, "the effect cannot be observed, so it is not verified", fixture);
|
|
3539
|
+
continue;
|
|
3540
|
+
}
|
|
3541
|
+
if (!canFillPath(listOp.path, scope)) {
|
|
3542
|
+
const missing = listOp.pathParams.filter((name) => scope[name] === undefined);
|
|
3543
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} declares an effect on "${effect.entity}", but the list path cannot be filled`, `missing ${missing.map((name) => `{${name}}`).join(", ")} after the write; the effect is unverified`, fixture);
|
|
3544
|
+
continue;
|
|
3545
|
+
}
|
|
3546
|
+
const after = await observe(ctx, listOp, scope);
|
|
3547
|
+
if (after.status === "error") {
|
|
3548
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not observe "${effect.entity}" after the write`, `list returned ${after.exchange.status}; the declared effect is unverified`, fixture);
|
|
3549
|
+
continue;
|
|
3550
|
+
}
|
|
3551
|
+
if (after.status === "unfillable") {
|
|
3552
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} declares an effect on "${effect.entity}", but the list path cannot be filled`, "the child list still lacks a parent id after the write; the effect is unverified", fixture);
|
|
3553
|
+
continue;
|
|
3554
|
+
}
|
|
3555
|
+
const before = befores.get(effect.entity);
|
|
3556
|
+
if (before?.status === "error") {
|
|
3557
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not observe "${effect.entity}" before the write`, `list returned ${before.exchange.status}; no baseline, so the effect is unverified`, fixture);
|
|
3558
|
+
continue;
|
|
3559
|
+
}
|
|
3560
|
+
const prior = before?.status === "ok" ? before.ids : [];
|
|
3561
|
+
const delta = after.ids.length - prior.length;
|
|
3562
|
+
const added = after.ids.filter((id) => !prior.includes(id));
|
|
3563
|
+
const removed = prior.filter((id) => !after.ids.includes(id));
|
|
3564
|
+
if (effectHolds(effect, delta, added.length, removed.length)) {
|
|
3565
|
+
if (effect.op === "create") {
|
|
3566
|
+
Object.assign(scope, bindCreatedScope(ctx.model, effect.entity, invoked.responseBody, added));
|
|
3567
|
+
}
|
|
3568
|
+
continue;
|
|
3569
|
+
}
|
|
3570
|
+
const hold = describeEffectHold(effect);
|
|
3571
|
+
if (effect.op === "create" || effect.op === "append") {
|
|
3572
|
+
ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} did not produce the "${effect.entity}" records it declares`, `x-effects declares ${hold} on "${effect.entity}", but the ` +
|
|
3573
|
+
`collection went from ${prior.length} to ${after.ids.length} ` +
|
|
3574
|
+
`(${added.length} added, ${removed.length} removed). A declared effect that does ` +
|
|
3575
|
+
"not occur means callers cannot rely on the operation having done anything.", [invoked, after.exchange], fixture);
|
|
3576
|
+
continue;
|
|
3577
|
+
}
|
|
3578
|
+
if (effect.op === "delete") {
|
|
3579
|
+
ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} did not remove the "${effect.entity}" records it declares`, `x-effects declares ${hold} on "${effect.entity}", but the collection ` +
|
|
3580
|
+
`went from ${prior.length} to ${after.ids.length}`, [invoked, after.exchange], fixture);
|
|
3581
|
+
continue;
|
|
3582
|
+
}
|
|
3583
|
+
ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} changed the size of "${effect.entity}" while declaring ${effect.op}`, `x-effects declares ${effect.op}, which must not add or remove records, but the ` +
|
|
3584
|
+
`collection went from ${prior.length} to ${after.ids.length}`, [invoked, after.exchange], fixture);
|
|
3528
3585
|
}
|
|
3529
|
-
}
|
|
3586
|
+
});
|
|
3530
3587
|
}
|
|
3531
3588
|
},
|
|
3532
3589
|
};
|
|
@@ -3550,58 +3607,74 @@ const sideEffectArrives = {
|
|
|
3550
3607
|
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} x-wait names "${spec.operationId}", which is not in the document`, "the side effect cannot be observed, so it is not verified");
|
|
3551
3608
|
continue;
|
|
3552
3609
|
}
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3610
|
+
await forEachInvocation(op.operationId, ctx.uploads, async (uploads, slot) => {
|
|
3611
|
+
const fixture = slot?.filename;
|
|
3612
|
+
const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
|
|
3613
|
+
const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3614
|
+
headers: ctx.auth(),
|
|
3615
|
+
operationId: op.operationId,
|
|
3616
|
+
...(fixture === undefined ? {} : { fixture }),
|
|
3617
|
+
...(body === undefined ? {} : await encodeOpBody(ctx, op, body, "baseline", 0, uploads)),
|
|
3618
|
+
});
|
|
3619
|
+
if (standDownForFeatureGate(ctx, op, invoked, this.id))
|
|
3620
|
+
return;
|
|
3621
|
+
if (standDownForRateLimit(ctx, invoked, this.id))
|
|
3622
|
+
return;
|
|
3623
|
+
if (invoked.status >= 400) {
|
|
3624
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared x-wait is unverified`, fixture);
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3627
|
+
const scope = bindWaitScope(ctx, op, pollOp, invoked.responseBody);
|
|
3628
|
+
const outcome = await driveWait({
|
|
3629
|
+
awaitSideEffect: ctx.hooks.awaitSideEffect,
|
|
3630
|
+
client: ctx.client,
|
|
3631
|
+
headers: ctx.auth,
|
|
3632
|
+
pollOp,
|
|
3633
|
+
record: invoked.responseBody,
|
|
3634
|
+
scope,
|
|
3635
|
+
spec,
|
|
3636
|
+
writeOpId: op.operationId,
|
|
3637
|
+
...(ctx.refreshIfStale === undefined ? {} : { refreshIfStale: ctx.refreshIfStale }),
|
|
3638
|
+
});
|
|
3639
|
+
if (!outcome.timedOut)
|
|
3640
|
+
return;
|
|
3641
|
+
ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} side effect did not appear within ${spec.timeoutMs}ms`, `x-wait polls ${spec.operationId}` +
|
|
3642
|
+
(spec.until === undefined ? "" : ` until ${spec.until} is non-empty`) +
|
|
3643
|
+
` and the path was still empty after ${outcome.polls} poll(s) / ${Math.round(outcome.elapsedMs)}ms. ` +
|
|
3644
|
+
"Queue consumers and webhook inboxes are not the same request; a timeout here is a " +
|
|
3645
|
+
"missed delivery, not a coverage gap.", [invoked, ...outcome.exchanges.slice(-2)], fixture);
|
|
3577
3646
|
});
|
|
3578
|
-
if (!outcome.timedOut)
|
|
3579
|
-
continue;
|
|
3580
|
-
ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} side effect did not appear within ${spec.timeoutMs}ms`, `x-wait polls ${spec.operationId}` +
|
|
3581
|
-
(spec.until === undefined ? "" : ` until ${spec.until} is non-empty`) +
|
|
3582
|
-
` and the path was still empty after ${outcome.polls} poll(s) / ${Math.round(outcome.elapsedMs)}ms. ` +
|
|
3583
|
-
"Queue consumers and webhook inboxes are not the same request; a timeout here is a " +
|
|
3584
|
-
"missed delivery, not a coverage gap.", [invoked, ...outcome.exchanges.slice(-2)]);
|
|
3585
3647
|
}
|
|
3586
3648
|
},
|
|
3587
3649
|
};
|
|
3588
|
-
|
|
3650
|
+
function listOpFor(ctx, entityName) {
|
|
3651
|
+
const listId = ctx.model.entities.get(entityName)?.list;
|
|
3652
|
+
return listId === undefined ? undefined : ctx.model.byOperationId.get(listId);
|
|
3653
|
+
}
|
|
3654
|
+
function bindWaitScope(ctx, writeOp, pollOp, writeBody) {
|
|
3655
|
+
const scope = { ...ctx.scope };
|
|
3656
|
+
Object.assign(scope, bindAfterCreateEffects(ctx.model, writeOp.effects, writeBody));
|
|
3657
|
+
Object.assign(scope, bindMissingPathParams(pollOp.pathParams, scope, writeBody));
|
|
3658
|
+
return scope;
|
|
3659
|
+
}
|
|
3660
|
+
async function observe(ctx, listOp, scope = ctx.scope) {
|
|
3589
3661
|
let path;
|
|
3590
3662
|
try {
|
|
3591
|
-
path = fillPath(listOp.path,
|
|
3663
|
+
path = fillPath(listOp.path, scope);
|
|
3592
3664
|
}
|
|
3593
3665
|
catch {
|
|
3594
|
-
return
|
|
3666
|
+
return { status: "unfillable" };
|
|
3595
3667
|
}
|
|
3596
3668
|
const exchange = await ctx.client.get(path, {
|
|
3597
3669
|
headers: ctx.auth(),
|
|
3670
|
+
operationId: listOp.operationId,
|
|
3598
3671
|
query: { limit: listOp.query?.maxLimit ?? 100 },
|
|
3599
3672
|
});
|
|
3600
3673
|
if (exchange.status >= 400)
|
|
3601
|
-
return
|
|
3674
|
+
return { exchange, status: "error" };
|
|
3602
3675
|
const identity = ctx.model.entities.get(listOp.entity ?? "")?.identity ?? "id";
|
|
3603
3676
|
const items = extractItems(exchange.responseBody, listOp.collection?.key ?? null);
|
|
3604
|
-
return { exchange, ids: items.map((item) => String(item[identity])) };
|
|
3677
|
+
return { exchange, ids: items.map((item) => String(item[identity])), status: "ok" };
|
|
3605
3678
|
}
|
|
3606
3679
|
const asyncReachesTerminalState = {
|
|
3607
3680
|
applicable: (ctx) => ctx.asyncOps.length > 0,
|
|
@@ -3613,47 +3686,50 @@ const asyncReachesTerminalState = {
|
|
|
3613
3686
|
const spec = op.async;
|
|
3614
3687
|
if (spec === null)
|
|
3615
3688
|
continue;
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
const fromStream = streamed ? inspectStreamAsync(start.responseBody, spec) : null;
|
|
3629
|
-
if (fromStream?.terminal !== null && fromStream?.terminal !== undefined) {
|
|
3630
|
-
if (spec.successWhen !== undefined && !matchesPredicate(fromStream.terminal, spec.successWhen)) {
|
|
3631
|
-
ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} reached a non-success terminal state`, `terminal state did not satisfy "${spec.successWhen}"; downstream effects of ` +
|
|
3632
|
-
"this operation are untested");
|
|
3689
|
+
await forEachInvocation(op.operationId, ctx.uploads, async (uploads, slot) => {
|
|
3690
|
+
const fixture = slot?.filename;
|
|
3691
|
+
const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
|
|
3692
|
+
const start = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
|
|
3693
|
+
headers: ctx.auth(),
|
|
3694
|
+
...(body === undefined ? {} : await encodeOpBody(ctx, op, body, "baseline", 0, uploads)),
|
|
3695
|
+
});
|
|
3696
|
+
if (standDownForFeatureGate(ctx, op, start, this.id))
|
|
3697
|
+
return;
|
|
3698
|
+
if (start.status >= 400) {
|
|
3699
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not be started`, `returned ${start.status}; the async lifecycle after it is untested`, fixture);
|
|
3700
|
+
return;
|
|
3633
3701
|
}
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
"
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3702
|
+
const streamed = isEventStream(start, op);
|
|
3703
|
+
const fromStream = streamed ? inspectStreamAsync(start.responseBody, spec) : null;
|
|
3704
|
+
if (fromStream?.terminal !== null && fromStream?.terminal !== undefined) {
|
|
3705
|
+
if (spec.successWhen !== undefined && !matchesPredicate(fromStream.terminal, spec.successWhen)) {
|
|
3706
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} reached a non-success terminal state`, `terminal state did not satisfy "${spec.successWhen}"; downstream effects of ` +
|
|
3707
|
+
"this operation are untested", fixture);
|
|
3708
|
+
}
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
const receipt = fromStream?.idRecord !== null && fromStream?.idRecord !== undefined ? fromStream.idRecord : start.responseBody;
|
|
3712
|
+
if (streamed && fromStream !== null && (fromStream.id === undefined || fromStream.id === null)) {
|
|
3713
|
+
ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} job disappeared before completing`, "the stream ended without a terminal frame and without a job id, so the poll route cannot be named.", [start], fixture);
|
|
3714
|
+
return;
|
|
3715
|
+
}
|
|
3716
|
+
const outcome = await driveAsync(ctx.client, spec, receipt, ctx.scope, ctx.auth, ctx.refreshIfStale);
|
|
3717
|
+
if (outcome.timedOut) {
|
|
3718
|
+
ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} never reached a terminal state`, `polled ${spec.poll} ${outcome.polls} time(s) over ${Math.round(outcome.elapsedMs)}ms ` +
|
|
3719
|
+
`without satisfying "${spec.until ?? "any terminal state"}". A job that neither ` +
|
|
3720
|
+
"completes nor fails leaves callers polling forever.", [start, ...outcome.exchanges.slice(-2)], fixture);
|
|
3721
|
+
return;
|
|
3722
|
+
}
|
|
3723
|
+
if (outcome.terminal === null) {
|
|
3724
|
+
ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} job disappeared before completing`, `the poll route stopped serving the job after ${outcome.polls} poll(s). A job that ` +
|
|
3725
|
+
"vanishes is indistinguishable from one that never existed.", [start, ...outcome.exchanges.slice(-2)], fixture);
|
|
3726
|
+
return;
|
|
3727
|
+
}
|
|
3728
|
+
if (!outcome.succeeded) {
|
|
3729
|
+
ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} reached a non-success terminal state`, `terminal state did not satisfy "${spec.successWhen ?? ""}"; downstream effects of ` +
|
|
3730
|
+
"this operation are untested", fixture);
|
|
3731
|
+
}
|
|
3732
|
+
});
|
|
3657
3733
|
}
|
|
3658
3734
|
},
|
|
3659
3735
|
};
|