@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.
Files changed (59) hide show
  1. package/README.md +181 -136
  2. package/dist/cli.js +19 -1
  3. package/dist/config/define-config.d.ts +29 -4
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/report/console.d.ts.map +1 -1
  7. package/dist/report/console.js +1 -0
  8. package/dist/report/console.js.map +1 -1
  9. package/dist/report/matrix.d.ts.map +1 -1
  10. package/dist/report/matrix.js +4 -1
  11. package/dist/report/matrix.js.map +1 -1
  12. package/dist/report/render.d.ts +4 -0
  13. package/dist/report/render.d.ts.map +1 -1
  14. package/dist/report/render.js +12 -2
  15. package/dist/report/render.js.map +1 -1
  16. package/dist/runtime/body.d.ts.map +1 -1
  17. package/dist/runtime/body.js +18 -2
  18. package/dist/runtime/body.js.map +1 -1
  19. package/dist/runtime/checks.d.ts.map +1 -1
  20. package/dist/runtime/checks.js +199 -123
  21. package/dist/runtime/checks.js.map +1 -1
  22. package/dist/runtime/client.d.ts +7 -1
  23. package/dist/runtime/client.d.ts.map +1 -1
  24. package/dist/runtime/client.js +3 -1
  25. package/dist/runtime/client.js.map +1 -1
  26. package/dist/runtime/effects.d.ts +43 -0
  27. package/dist/runtime/effects.d.ts.map +1 -0
  28. package/dist/runtime/effects.js +141 -0
  29. package/dist/runtime/effects.js.map +1 -0
  30. package/dist/runtime/exchanges.d.ts +37 -0
  31. package/dist/runtime/exchanges.d.ts.map +1 -0
  32. package/dist/runtime/exchanges.js +298 -0
  33. package/dist/runtime/exchanges.js.map +1 -0
  34. package/dist/runtime/finding.d.ts +6 -4
  35. package/dist/runtime/finding.d.ts.map +1 -1
  36. package/dist/runtime/finding.js +13 -8
  37. package/dist/runtime/finding.js.map +1 -1
  38. package/dist/runtime/run.d.ts +9 -0
  39. package/dist/runtime/run.d.ts.map +1 -1
  40. package/dist/runtime/run.js +17 -1
  41. package/dist/runtime/run.js.map +1 -1
  42. package/dist/runtime/upload-each.d.ts +14 -0
  43. package/dist/runtime/upload-each.d.ts.map +1 -0
  44. package/dist/runtime/upload-each.js +69 -0
  45. package/dist/runtime/upload-each.js.map +1 -0
  46. package/dist/runtime/upload.d.ts +8 -0
  47. package/dist/runtime/upload.d.ts.map +1 -1
  48. package/dist/runtime/upload.js +26 -4
  49. package/dist/runtime/upload.js.map +1 -1
  50. package/dist/runtime/world.d.ts.map +1 -1
  51. package/dist/runtime/world.js +57 -27
  52. package/dist/runtime/world.js.map +1 -1
  53. package/dist/spec/extensions.d.ts +4 -1
  54. package/dist/spec/extensions.d.ts.map +1 -1
  55. package/dist/spec/extensions.js +8 -2
  56. package/dist/spec/extensions.js.map +1 -1
  57. package/dist/spec/graph.js +1 -1
  58. package/dist/spec/graph.js.map +1 -1
  59. package/package.json +1 -1
@@ -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, ctx.uploads, variant, index);
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 exactly the stated change.
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 exact assertion on cardinality and membership.
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
- for (const effect of op.effects) {
3475
- const target = ctx.model.entities.get(effect.entity);
3476
- const listId = target?.list;
3477
- if (listId === undefined) {
3478
- ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} declares an effect on "${effect.entity}", which has no list route`, "the effect cannot be observed, so it is not verified");
3479
- continue;
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
- ...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
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
- continue;
3508
+ return;
3494
3509
  if (standDownForRateLimit(ctx, invoked, this.id))
3495
- continue;
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
- continue;
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 after = await observe(ctx, listOp);
3501
- if (after === null)
3502
- continue;
3503
- const expected = effect.count ?? 1;
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
- ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} did not produce the "${effect.entity}" records it declares`, `x-effects declares ${effect.op} × ${expected} on "${effect.entity}", but the ` +
3512
- `collection went from ${before.ids.length} to ${after.ids.length} ` +
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
- ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} did not remove the "${effect.entity}" records it declares`, `x-effects declares delete × ${expected} on "${effect.entity}", but the collection ` +
3521
- `went from ${before.ids.length} to ${after.ids.length}`, [invoked, after.exchange]);
3522
- continue;
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
- /* update and replace change content, not cardinality. */
3525
- if (delta !== 0) {
3526
- ctx.findings.backend(this.id, ctx.entityName, `${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 ` +
3527
- `collection went from ${before.ids.length} to ${after.ids.length}`, [invoked, after.exchange]);
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
- const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3554
- const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3555
- headers: ctx.auth(),
3556
- operationId: op.operationId,
3557
- ...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
3558
- });
3559
- if (standDownForFeatureGate(ctx, op, invoked, this.id))
3560
- continue;
3561
- if (standDownForRateLimit(ctx, invoked, this.id))
3562
- continue;
3563
- if (invoked.status >= 400) {
3564
- ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared x-wait is unverified`);
3565
- continue;
3566
- }
3567
- const outcome = await driveWait({
3568
- awaitSideEffect: ctx.hooks.awaitSideEffect,
3569
- client: ctx.client,
3570
- headers: ctx.auth,
3571
- pollOp,
3572
- record: invoked.responseBody,
3573
- scope: ctx.scope,
3574
- spec,
3575
- writeOpId: op.operationId,
3576
- ...(ctx.refreshIfStale === undefined ? {} : { refreshIfStale: ctx.refreshIfStale }),
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
- async function observe(ctx, listOp) {
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, ctx.scope);
3663
+ path = fillPath(listOp.path, scope);
3592
3664
  }
3593
3665
  catch {
3594
- return null;
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 null;
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
- const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3617
- const start = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3618
- headers: ctx.auth(),
3619
- ...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
3620
- });
3621
- if (standDownForFeatureGate(ctx, op, start, this.id))
3622
- continue;
3623
- if (start.status >= 400) {
3624
- ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be started`, `returned ${start.status}; the async lifecycle after it is untested`);
3625
- continue;
3626
- }
3627
- const streamed = isEventStream(start, op);
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
- continue;
3635
- }
3636
- const receipt = fromStream?.idRecord !== null && fromStream?.idRecord !== undefined ? fromStream.idRecord : start.responseBody;
3637
- if (streamed && fromStream !== null && (fromStream.id === undefined || fromStream.id === null)) {
3638
- ctx.findings.backend(this.id, ctx.entityName, `${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]);
3639
- continue;
3640
- }
3641
- const outcome = await driveAsync(ctx.client, spec, receipt, ctx.scope, ctx.auth, ctx.refreshIfStale);
3642
- if (outcome.timedOut) {
3643
- ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} never reached a terminal state`, `polled ${spec.poll} ${outcome.polls} time(s) over ${Math.round(outcome.elapsedMs)}ms ` +
3644
- `without satisfying "${spec.until ?? "any terminal state"}". A job that neither ` +
3645
- "completes nor fails leaves callers polling forever.", [start, ...outcome.exchanges.slice(-2)]);
3646
- continue;
3647
- }
3648
- if (outcome.terminal === null) {
3649
- ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} job disappeared before completing`, `the poll route stopped serving the job after ${outcome.polls} poll(s). A job that ` +
3650
- "vanishes is indistinguishable from one that never existed.", [start, ...outcome.exchanges.slice(-2)]);
3651
- continue;
3652
- }
3653
- if (!outcome.succeeded) {
3654
- 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 ` +
3655
- "this operation are untested");
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
  };