@sanity/workflow-engine-test 0.8.0 → 0.10.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/index.d.cts CHANGED
@@ -1,35 +1,51 @@
1
+ import type { AbortInstanceArgs } from "@sanity/workflow-engine";
1
2
  import { ActivityStatus } from "@sanity/workflow-engine";
2
3
  import { Actor } from "@sanity/workflow-engine";
4
+ import { CandidateDocument } from "@sanity/workflow-engine";
5
+ import { Clock } from "@sanity/workflow-engine";
3
6
  import type { CompleteEffectArgs } from "@sanity/workflow-engine";
7
+ import { CreateEngineArgs } from "@sanity/workflow-engine";
8
+ import type { DeclaredExecutionContext } from "@sanity/workflow-engine";
4
9
  import { DeployDefinitionsArgs } from "@sanity/workflow-engine";
5
10
  import { DeployDefinitionsResult } from "@sanity/workflow-engine";
6
- import { DispatchResult } from "@sanity/workflow-engine";
11
+ import { DeployedDefinition } from "@sanity/workflow-engine";
7
12
  import type { EditFieldArgs } from "@sanity/workflow-engine";
13
+ import { Engine } from "@sanity/workflow-engine";
8
14
  import type { EvaluateArgs } from "@sanity/workflow-engine";
9
15
  import type { FireActionArgs } from "@sanity/workflow-engine";
10
16
  import { GdrUri } from "@sanity/workflow-engine";
11
17
  import type { Grant } from "@sanity/workflow-engine";
18
+ import { InitialFieldValue } from "@sanity/workflow-engine";
12
19
  import { MutationGuardAction } from "@sanity/workflow-engine";
13
20
  import { MutationGuardDoc } from "@sanity/workflow-engine";
14
21
  import type { OperationArgs } from "@sanity/workflow-engine";
22
+ import { OperationResult } from "@sanity/workflow-engine";
15
23
  import { PendingEffect } from "@sanity/workflow-engine";
24
+ import type { SetStageArgs } from "@sanity/workflow-engine";
16
25
  import type { StartInstanceArgs } from "@sanity/workflow-engine";
17
26
  import type { WorkflowAccess } from "@sanity/workflow-engine";
27
+ import { WorkflowDefinitionInput } from "@sanity/workflow-engine";
18
28
  import { WorkflowEvaluation } from "@sanity/workflow-engine";
19
29
  import { WorkflowInstance } from "@sanity/workflow-engine";
20
30
  import type { WorkflowResource } from "@sanity/workflow-engine";
21
31
 
22
32
  declare type Action = ReleaseAction | VersionAction;
23
33
 
34
+ /** Options for `action()`. Only `tag` is meaningful to the fake. */
35
+ declare interface ActionOptions {
36
+ /** Request tag, recorded on the {@link CallRecord} (`null` = explicit suppression). */
37
+ tag?: string | null;
38
+ }
39
+
24
40
  declare interface ActionResult {
25
41
  transactionId: string;
26
42
  }
27
43
 
28
44
  /**
29
- * The bench's default access state — wildcard user + wildcard
30
- * grants. The bench injects this as the `access` override on every
31
- * engine call so the underlying TestClient (which has no token, so
32
- * no `/users/me` resolution path) never needs to do auth resolution.
45
+ * The bench's default identity — wildcard user + wildcard grants,
46
+ * registered as the default token on the bench's fake client so the
47
+ * engine token-resolves it (`/users/me` + the ACL path) exactly like
48
+ * production.
33
49
  *
34
50
  * Production code never sees this; pass `createBench({ access })` to
35
51
  * test denial paths.
@@ -51,73 +67,73 @@ export declare type Bench = BenchState &
51
67
  EngineWrappers &
52
68
  ReadHelpers &
53
69
  GuardHelpers &
54
- QueryHelpers;
70
+ QueryHelpers &
71
+ ComposedHelpers;
55
72
 
56
- declare type BenchCompleteEffectArgs = Omit<
57
- CompleteEffectArgs,
58
- "client" | "tag" | "workflowResource" | "access"
59
- > & {
60
- access?: WorkflowAccess;
61
- /** Shortcut for `access.actor`. */
62
- actor?: Actor;
63
- };
73
+ /** Default engine-scope partition tag for benches that don't pass one. */
74
+ export declare const BENCH_TAG = "bench";
64
75
 
65
- declare type BenchEditFieldArgs = Omit<
66
- EditFieldArgs,
67
- "client" | "tag" | "workflowResource" | "access"
68
- > & {
69
- access?: WorkflowAccess;
70
- /** Shortcut for `access.actor`. */
71
- actor?: Actor;
72
- /** Shortcut for `access.grants`. */
73
- grants?: Grant[];
74
- };
76
+ declare type BenchAbortInstanceArgs = BenchArgs<AbortInstanceArgs>;
75
77
 
76
- declare type BenchEvaluateArgs = Omit<
77
- EvaluateArgs,
78
- "client" | "tag" | "workflowResource" | "access"
79
- > & {
78
+ /**
79
+ * A bench verb's args: the engine verb's args plus the bench's per-call
80
+ * identity shortcuts the composed pair runs the verb through a
81
+ * token-scoped sibling client.
82
+ */
83
+ declare type BenchArgs<T> = T & {
80
84
  access?: WorkflowAccess;
81
85
  /** Shortcut for `access.actor`. */
82
86
  actor?: Actor;
83
- /** Shortcut for `access.grants`. */
84
- grants?: Grant[];
85
87
  };
86
88
 
87
- declare type BenchFireActionArgs = Omit<
88
- FireActionArgs,
89
- "client" | "tag" | "workflowResource" | "access"
90
- > & {
91
- access?: WorkflowAccess;
92
- /** Shortcut for `access.actor`. */
93
- actor?: Actor;
89
+ /** {@link BenchArgs} for the verbs that also take the `grants` shortcut. */
90
+ declare type BenchArgsWithGrants<T> = BenchArgs<T> & {
94
91
  /** Shortcut for `access.grants`. */
95
92
  grants?: Grant[];
96
93
  };
97
94
 
98
- declare type BenchStartInstanceArgs = Omit<
99
- StartInstanceArgs,
100
- "client" | "tag" | "workflowResource" | "access"
101
- > & {
102
- access?: WorkflowAccess;
103
- /** Shortcut for `access.actor`. */
104
- actor?: Actor;
105
- };
95
+ declare type BenchCompleteEffectArgs = BenchArgs<CompleteEffectArgs>;
96
+
97
+ declare type BenchEditFieldArgs = BenchArgsWithGrants<EditFieldArgs>;
98
+
99
+ declare type BenchEvaluateArgs = BenchArgsWithGrants<EvaluateArgs>;
100
+
101
+ declare type BenchFireActionArgs = BenchArgsWithGrants<FireActionArgs>;
102
+
103
+ declare type BenchSetStageArgs = BenchArgs<SetStageArgs>;
104
+
105
+ declare type BenchStartInstanceArgs = BenchArgs<StartInstanceArgs>;
106
106
 
107
107
  /** Base bench state, distinct from the engine/read/guard/query helper groups. */
108
108
  declare interface BenchState {
109
109
  /** The fake Sanity client. Passed automatically by bench wrappers. */
110
110
  client: TestClient;
111
+ /** The engine-scope partition tag every wrapped call runs under. */
112
+ tag: string;
113
+ /** The workflow resource every wrapped call targets. */
114
+ workflowResource: WorkflowResource;
115
+ /** The declared execution context stamped on every wrapped call's history
116
+ * entries (default `{kind: 'test'}`) — inherited by {@link createBenchEngine}. */
117
+ executionContext: DeclaredExecutionContext;
111
118
  /**
112
- * The access state (actor + grants) the bench injects as the
113
- * `access` override on every wrapped engine call. Defaults to
114
- * `ALL_ACCESS`.
119
+ * The bench's default identity (actor + grants) registered as the
120
+ * default token on the fake client. Defaults to `ALL_ACCESS`.
115
121
  */
116
122
  access: WorkflowAccess;
117
123
  /** Convenience: `access.actor`. */
118
124
  currentUser: Actor;
119
125
  /** Convenience: `access.grants ?? []`. */
120
126
  grants: Grant[];
127
+ /**
128
+ * A token-bound sibling client acting as `access` — the bench's ONE
129
+ * identity-registration seam. Registers the pair on the fake client's
130
+ * family-shared registry (idempotent, cached per identity; omitted
131
+ * grants default to wildcard) and returns the sibling whose
132
+ * `/users/me` + ACL path serve it — the engine token-resolves it like
133
+ * production. Bind it to {@link createBenchEngine} to act as another
134
+ * user end-to-end; never hand-roll `addIdentity` + `withConfig`.
135
+ */
136
+ clientFor: (access: WorkflowAccess) => TestClient;
121
137
  /** Add documents to the store after construction. */
122
138
  seedDocuments: (documents: StoreDocument[]) => void;
123
139
  /**
@@ -142,14 +158,7 @@ declare interface BenchState {
142
158
  advance: (ms: number) => void;
143
159
  }
144
160
 
145
- declare type BenchTickArgs = Omit<
146
- OperationArgs,
147
- "client" | "tag" | "workflowResource" | "access"
148
- > & {
149
- access?: WorkflowAccess;
150
- /** Shortcut for `access.actor`. */
151
- actor?: Actor;
152
- };
161
+ declare type BenchTickArgs = BenchArgs<OperationArgs>;
153
162
 
154
163
  /**
155
164
  * One recorded invocation of a client method — the spy layer behind
@@ -167,10 +176,27 @@ declare interface CallRecord {
167
176
  result?: unknown;
168
177
  /** Thrown or rejected value, set if the call failed. */
169
178
  error?: unknown;
179
+ /** Request tag from the call's options, when one was passed (`null` = explicit suppression). */
180
+ tag?: string | null;
170
181
  projectId: string;
171
182
  dataset: string;
172
183
  }
173
184
 
185
+ /** Helpers composed across the wrapper + read groups at bench assembly. */
186
+ declare interface ComposedHelpers {
187
+ /**
188
+ * Complete the pending effect named `effect` — {@link EngineWrappers.completeEffect}
189
+ * with the `effectKey` looked up by name, throwing loud when no such effect
190
+ * is pending. The idiomatic way to play an external handler reporting its
191
+ * result; pass `ops` to write outcome fields in the same commit.
192
+ */
193
+ completePendingEffect: (
194
+ args: Omit<BenchCompleteEffectArgs, "effectKey"> & {
195
+ effect: string;
196
+ },
197
+ ) => Promise<OperationResult>;
198
+ }
199
+
174
200
  /**
175
201
  * Create a fresh bench. By default the bench is fully isolated — two
176
202
  * `createBench()` calls have no shared state. Pass `client` + `tag`
@@ -178,14 +204,28 @@ declare interface CallRecord {
178
204
  */
179
205
  export declare function createBench(options?: CreateBenchOptions): Bench;
180
206
 
207
+ /**
208
+ * Build an out-of-band {@link Engine} on a bench's client, inheriting the
209
+ * bench's tag, workflow resource, and clock (so `setNow` / `advance` govern
210
+ * the engine's `$now` too). Use this when a test needs an engine of its own —
211
+ * custom effect handlers, racing two drainers — rather than the wrappers the
212
+ * bench exposes. `overrides` win over every inherited value.
213
+ */
214
+ export declare function createBenchEngine(
215
+ bench: EngineScope & {
216
+ now: Clock;
217
+ },
218
+ overrides?: Partial<CreateEngineArgs>,
219
+ ): Engine;
220
+
181
221
  export declare interface CreateBenchOptions {
182
222
  /** Subject documents seeded into the store. */
183
223
  documents?: StoreDocument[];
184
224
  /**
185
- * Access state (actor + grants) injected as the engine `access`
186
- * override on every wrapped call. Defaults to `ALL_ACCESS`
187
- * wildcard user + wildcard grants. Pass a restrictive value to
188
- * test denial paths.
225
+ * The bench's default identity (actor + grants), registered as the
226
+ * default token on the fake client the engine token-resolves it like
227
+ * production. Defaults to `ALL_ACCESS` wildcard user + wildcard
228
+ * grants. Pass a restrictive value to test denial paths.
189
229
  *
190
230
  * Mutually exclusive with the `currentUser` / `grants` shortcuts
191
231
  * (which compose into the same shape internally).
@@ -196,28 +236,7 @@ export declare interface CreateBenchOptions {
196
236
  /** Shortcut for `access.grants`. Composed with `currentUser`. */
197
237
  grants?: Grant[];
198
238
  /**
199
- * Opt-in: simulate Sanity's hard write-gate inside the TestClient.
200
- *
201
- * When set, the bench's underlying TestClient:
202
- * - Exposes `client.request({ url })` serving `/users/me` + the
203
- * configured ACL path, so the engine can resolve `WorkflowAccess`
204
- * from "the token" exactly like production.
205
- * - Rejects writes the grants don't permit with a Sanity-style
206
- * `WriteAccessDeniedError` — the same 403 a real lake would
207
- * return.
208
- *
209
- * Use this to demonstrate what would happen if the engine's
210
- * soft-gate were bypassed (or disagreed with) — the lake is still
211
- * authoritative. The bench's `access` field is the engine-side
212
- * override; `accessControl` is the lake-side enforcement. They
213
- * usually carry the same `actor` + `grants` but can be made to
214
- * disagree to test edge cases.
215
- *
216
- * Mutually exclusive with passing a pre-built `client`.
217
- */
218
- accessControl?: TestClientAccessControl;
219
- /**
220
- * Engine-scope environment partition. Defaults to `"bench"`. Pass
239
+ * Engine-scope environment partition. Defaults to {@link BENCH_TAG}. Pass
221
240
  * different tags to two benches sharing a `client` to assert partition
222
241
  * isolation; pass the same tag to assert visibility through a shared
223
242
  * partition.
@@ -247,6 +266,12 @@ export declare interface CreateBenchOptions {
247
266
  * start on the wall clock (`advance` / `setNow` can freeze it later).
248
267
  */
249
268
  now?: string;
269
+ /**
270
+ * Declared execution context stamped (with the inferred runtime) on every
271
+ * history entry the bench's engine calls append. Defaults to
272
+ * `{ kind: "test" }`; override to exercise provenance rendering.
273
+ */
274
+ executionContext?: DeclaredExecutionContext;
250
275
  }
251
276
 
252
277
  declare interface CurrentUser {
@@ -279,17 +304,44 @@ declare interface DocumentBody {
279
304
 
280
305
  declare type DocumentValuePermission = "create" | "read" | "update";
281
306
 
282
- /** Engine-call wrappers that inject the bench-owned client + tag + access. */
307
+ /**
308
+ * How {@link GuardHelpers.editDocument} mutates the document. Each present
309
+ * key becomes one patch op, applied in `set` → `unset` → `insert` order in a
310
+ * single commit; the shapes mirror the client's patch builder.
311
+ */
312
+ export declare type EditDocumentPatch = {
313
+ set?: Record<string, unknown>;
314
+ unset?: string[];
315
+ insert?: {
316
+ position: "before" | "after" | "replace";
317
+ anchor: string;
318
+ items: unknown[];
319
+ };
320
+ };
321
+
322
+ /** The bench-owned engine call context injected into every wrapped verb. */
323
+ declare type EngineScope = {
324
+ client: TestClient;
325
+ tag: string;
326
+ workflowResource: WorkflowResource;
327
+ executionContext: DeclaredExecutionContext;
328
+ };
329
+
330
+ /** Engine-call wrappers that inject the bench-owned client + tag + identity. */
283
331
  declare type EngineWrappers = {
284
- deployDefinitions: (
285
- args: Omit<DeployDefinitionsArgs, "client" | "tag" | "workflowResource">,
332
+ deployDefinitions: <T extends WorkflowDefinitionInput<T>>(
333
+ args: DeployDefinitionsArgs<T>,
286
334
  ) => Promise<DeployDefinitionsResult>;
287
- startInstance: (args: BenchStartInstanceArgs) => Promise<WorkflowInstance>;
288
- fireAction: (args: BenchFireActionArgs) => Promise<DispatchResult>;
289
- editField: (args: BenchEditFieldArgs) => Promise<DispatchResult>;
290
- completeEffect: (args: BenchCompleteEffectArgs) => Promise<DispatchResult>;
291
- tick: (args: BenchTickArgs) => Promise<DispatchResult>;
335
+ startInstance: (args: BenchStartInstanceArgs) => Promise<OperationResult>;
336
+ fireAction: (args: BenchFireActionArgs) => Promise<OperationResult>;
337
+ editField: (args: BenchEditFieldArgs) => Promise<OperationResult>;
338
+ completeEffect: (args: BenchCompleteEffectArgs) => Promise<OperationResult>;
339
+ tick: (args: BenchTickArgs) => Promise<OperationResult>;
292
340
  evaluate: (args: BenchEvaluateArgs) => Promise<WorkflowEvaluation>;
341
+ /** Admin override — bypass filters/transitions and force the stage. */
342
+ setStage: (args: BenchSetStageArgs) => Promise<OperationResult>;
343
+ /** Admin override — hard-stop an in-flight instance where it stands. */
344
+ abortInstance: (args: BenchAbortInstanceArgs) => Promise<OperationResult>;
293
345
  guardsForInstance: (instanceId: string) => Promise<MutationGuardDoc[]>;
294
346
  guardsForDefinition: (definition: string) => Promise<MutationGuardDoc[]>;
295
347
  };
@@ -370,8 +422,12 @@ declare interface FetchOptions {
370
422
  * raw response. Ignored when `filterResponse` is not `false`.
371
423
  */
372
424
  returnQuery?: boolean;
373
- /** Accepted for signature parity; not simulated. */
374
- tag?: string;
425
+ /**
426
+ * Request tag, recorded on the {@link CallRecord} for assertions.
427
+ * `null` mirrors `@sanity/client`'s explicit-suppression form and is
428
+ * recorded as-is. No other behavior is simulated.
429
+ */
430
+ tag?: string | null;
375
431
  /** Accepted for signature parity; not simulated. */
376
432
  signal?: AbortSignal;
377
433
  }
@@ -382,7 +438,7 @@ declare interface Grant_2 {
382
438
  }
383
439
 
384
440
  declare type GuardHelpers = {
385
- /** All deployed `temp.system.guard` docs in the store. */
441
+ /** All deployed guard docs ({@link MutationGuardDoc}) in the store. */
386
442
  listGuards: () => Promise<MutationGuardDoc[]>;
387
443
  /**
388
444
  * Guards that would deny an arbitrary edit to `documentId` right now (i.e.
@@ -394,15 +450,17 @@ declare type GuardHelpers = {
394
450
  as?: Actor,
395
451
  ) => Promise<MutationGuardDoc[]>;
396
452
  /**
397
- * Write to a document through the client's enforced write seam — the bench
453
+ * Write to a document through the client's guarded write seam — the bench
398
454
  * client gates the write against deployed guards (incl. `identity()`) and
399
- * throws `MutationGuardDeniedError` on denial, exactly as the lake would.
400
- * `as` routes the write through a token-scoped sibling so `identity()`
401
- * resolves to that actor.
455
+ * throws `MutationGuardDeniedError` on denial the lake's intended
456
+ * rejection, simulated by the bench. `as` routes the write through a
457
+ * token-scoped sibling so `identity()` resolves to that actor. A `patch`
458
+ * ({@link EditDocumentPatch}) is required for edits and rejected for
459
+ * `action: 'delete'` — the combination is contradictory input.
402
460
  */
403
461
  editDocument: (opts: {
404
462
  documentId: string;
405
- patch: Record<string, unknown>;
463
+ patch?: EditDocumentPatch;
406
464
  action?: MutationGuardAction;
407
465
  as?: Actor;
408
466
  }) => Promise<StoreDocument>;
@@ -424,6 +482,8 @@ declare interface MutationLogEntry {
424
482
  projectId: string;
425
483
  dataset: string;
426
484
  mutations: TxnMutation[];
485
+ /** Request tag the commit carried, when one was passed (`null` = explicit suppression). */
486
+ tag?: string | null;
427
487
  }
428
488
 
429
489
  /** The operation recorded per affected document, matching `@sanity/client`. */
@@ -451,8 +511,21 @@ declare interface MutationOptions {
451
511
  visibility?: "sync" | "async" | "deferred";
452
512
  /** Accepted for signature parity; not simulated. */
453
513
  autoGenerateArrayKeys?: boolean;
514
+ /**
515
+ * Request tag, recorded on the {@link CallRecord} and the
516
+ * {@link MutationLogEntry} for assertions. `null` mirrors
517
+ * `@sanity/client`'s explicit-suppression form and is recorded as-is.
518
+ * No other behavior is simulated.
519
+ */
520
+ tag?: string | null;
521
+ }
522
+
523
+ /** Options for `PatchHandle.commit()`. Only `tag` is meaningful to the fake. */
524
+ declare interface PatchCommitOptions {
525
+ /** Request tag, recorded on the mutation log entry (`null` = explicit suppression). */
526
+ tag?: string | null;
454
527
  /** Accepted for signature parity; not simulated. */
455
- tag?: string;
528
+ visibility?: "sync" | "async" | "deferred";
456
529
  }
457
530
 
458
531
  declare interface PatchHandle {
@@ -467,7 +540,7 @@ declare interface PatchHandle {
467
540
  items: unknown[],
468
541
  ) => PatchHandle;
469
542
  ifRevisionId: (rev: string) => PatchHandle;
470
- commit: () => Promise<SanityDocument>;
543
+ commit: (options?: PatchCommitOptions) => Promise<SanityDocument>;
471
544
  /** Test-only introspection — not on real Patch */
472
545
  toJSON: () => {
473
546
  id: string;
@@ -561,9 +634,9 @@ declare type ReadHelpers = {
561
634
  * Spawned children of `parentInstanceId`. If `activity` is provided,
562
635
  * only children spawned by that specific activity on the parent.
563
636
  *
564
- * Walks `history.spawned` entries on the parent — this is the durable
565
- * record. (The per-activity `spawnedInstances` field only lives on the
566
- * open StageEntry's activities; history survives transitions.)
637
+ * Walks `history.spawned` entries on the parent — the durable record
638
+ * (the workflow-scope registry tracks the same children, keyed for
639
+ * adoption rather than enumeration).
567
640
  *
568
641
  * Returns children ordered by `startedAt` ascending.
569
642
  */
@@ -598,6 +671,15 @@ declare type ReadHelpers = {
598
671
  * non-reactive content forwarder needs. Sorted by `startedAt` ascending.
599
672
  */
600
673
  instancesForDocument: (document: GdrUri) => Promise<WorkflowInstance[]>;
674
+ /**
675
+ * The startable half of {@link ReadHelpers.instancesForDocument}: the
676
+ * latest deployed version of every definition applicable to the LOADED
677
+ * candidate document — startable, a required subject entry accepts its
678
+ * `_type`, `applicableWhen` passes. All matches, name ascending.
679
+ */
680
+ definitionsForDocument: (
681
+ document: CandidateDocument,
682
+ ) => Promise<DeployedDefinition[]>;
601
683
  /** Snapshot of all documents in the store — for inspecting the world. */
602
684
  snapshot: () => readonly StoreDocument[];
603
685
  };
@@ -650,6 +732,18 @@ declare interface ReleaseDocument extends SanityDocument {
650
732
  publishAt?: string;
651
733
  }
652
734
 
735
+ /**
736
+ * A `release.ref` entry pointing at `_.releases.<releaseName>` in the bench's
737
+ * default resource — what a release-targeting workflow's `release` field
738
+ * expects at start.
739
+ */
740
+ export declare function releaseField(
741
+ releaseName: string,
742
+ opts?: {
743
+ name?: string;
744
+ },
745
+ ): InitialFieldValue;
746
+
653
747
  declare interface ReleaseMetadata {
654
748
  title?: string;
655
749
  description?: string;
@@ -737,6 +831,19 @@ declare type StoreDocument = {
737
831
  [key: string]: unknown;
738
832
  };
739
833
 
834
+ /**
835
+ * The conventional workflow-scope `subject` doc.ref entry, keyed by a bare
836
+ * doc id in the bench's default resource. `type` defaults to `'document'`;
837
+ * pass the subject's schema type when a filter or projection reads it.
838
+ */
839
+ export declare function subjectField(
840
+ documentId: string,
841
+ opts?: {
842
+ name?: string;
843
+ type?: string;
844
+ },
845
+ ): InitialFieldValue;
846
+
740
847
  declare interface SystemFields {
741
848
  _rev: string;
742
849
  _createdAt: string;
@@ -758,22 +865,18 @@ declare interface TestClient {
758
865
  options?: FetchOptions,
759
866
  ) => Promise<(T | null)[]>;
760
867
  /**
761
- * Sanity-style HTTP request hook. Present iff the client was
762
- * created with `accessControl`. The engine's access resolver pokes
763
- * `/users/me` and the configured ACL path through this — without it
764
- * the engine throws on any verb that needs an actor (which is the
765
- * cue for the bench to supply an `access` override).
766
- *
767
- * Test cases that want to exercise the engine's token-resolution
768
- * path should configure `accessControl`; cases that want bench-style
769
- * always-allow should leave it off and pass `access` through the
770
- * bench.
868
+ * Sanity-style HTTP request hook. Present iff the handle has an
869
+ * acting identity — created with `accessControl`, or bound to a
870
+ * registered token (`identities` + `token`). The engine's access
871
+ * resolver pokes `/users/me` and the configured ACL path through
872
+ * this without it the engine throws on any verb that needs an
873
+ * actor.
771
874
  */
772
875
  request?: <T>(opts: {
773
876
  url?: string;
774
877
  uri?: string;
775
878
  signal?: AbortSignal;
776
- tag?: string;
879
+ tag?: string | null;
777
880
  }) => Promise<T>;
778
881
  create: <T extends SanityDocumentLike>(
779
882
  doc: T,
@@ -811,7 +914,10 @@ declare interface TestClient {
811
914
  * Returns a synthetic `transactionId`. Multi-action arrays are applied
812
915
  * atomically — any failure rolls back the batch.
813
916
  */
814
- action: (action: Action | Action[]) => Promise<ActionResult>;
917
+ action: (
918
+ action: Action | Action[],
919
+ options?: ActionOptions,
920
+ ) => Promise<ActionResult>;
815
921
  /** Helpers mirroring `@sanity/client`'s `client.releases.*` surface. */
816
922
  releases: ReleasesHandle;
817
923
  /** Helper for `sanity.action.document.version.create`. */
@@ -825,11 +931,15 @@ declare interface TestClient {
825
931
  releaseId: string;
826
932
  publishedId: string;
827
933
  }) => Promise<ActionResult>;
828
- /** Current `(projectId, dataset)` and active perspective. */
934
+ /**
935
+ * Current `(projectId, dataset)` and active perspective. `token` is
936
+ * present iff the handle is bound to a registered identity.
937
+ */
829
938
  config: () => {
830
939
  projectId: string;
831
940
  dataset: string;
832
941
  perspective: Perspective;
942
+ token?: string;
833
943
  };
834
944
  /**
835
945
  * Return a new client handle pointing at a different `(projectId, dataset)`
@@ -846,8 +956,18 @@ declare interface TestClient {
846
956
  * Rebind the acting identity (and its grants) on a sibling that shares
847
957
  * the same store — the test analogue of a token-scoped client. Lets two
848
958
  * actors write the same dataset, e.g. for `identity()`-based guards.
959
+ * Mutually exclusive with `token`.
849
960
  */
850
961
  accessControl: TestClientAccessControl;
962
+ /**
963
+ * Bind the sibling to a token registered in the family's identity
964
+ * registry (`createTestClient({ identities })` / `addIdentity`): its
965
+ * `/users/me` serves that identity's user, its ACL path serves that
966
+ * identity's grants, and reads/writes are gated through them — the
967
+ * token determines identity, exactly like production. Unknown tokens
968
+ * throw. Mutually exclusive with `accessControl`.
969
+ */
970
+ token: string;
851
971
  }>,
852
972
  ) => TestClient;
853
973
  /** Replace the current dataset with the given documents. */
@@ -864,9 +984,21 @@ declare interface TestClient {
864
984
  * Simulate `count` concurrent edits to `id` in the current dataset: the
865
985
  * next `count` revision-guarded mutations (those passing `ifRevisionId`)
866
986
  * to that document throw a 409-shaped conflict, as if another client
867
- * committed between the caller's read and write. Lets a test exercise
868
- * optimistic-locking retry paths deterministically, without timing or
869
- * interleaving. Mutations that don't pass `ifRevisionId` are unaffected.
987
+ * committed between the caller's read and write. Mutations that don't
988
+ * pass `ifRevisionId` are unaffected.
989
+ *
990
+ * Reach for this when the code under test reads a document's `_rev` and
991
+ * commits with `ifRevisionId` internally — so you can't stage the race
992
+ * by mutating the document first (the code would just re-read the new
993
+ * revision and its commit would match). The counter is consumed one per
994
+ * guarded commit, so `count` lets a test drive a recover-after-N-losses
995
+ * path or exhaust a retry budget, deterministically and without relying
996
+ * on promise interleaving.
997
+ *
998
+ * Note: this forces the conflict *outcome* — it short-circuits the
999
+ * revision comparison rather than changing the stored `_rev`. The code
1000
+ * under test sees the identical 409 error a real lost race produces; the
1001
+ * stored revision is left untouched.
870
1002
  */
871
1003
  simulateConcurrentEdit: (id: string, count?: number) => void;
872
1004
  /** List `(projectId, dataset)` pairs known in this registry. */
@@ -884,6 +1016,16 @@ declare interface TestClient {
884
1016
  stub: (name: string, handler: unknown) => void;
885
1017
  /** The currently-registered stubs, keyed by member name. */
886
1018
  stubs: () => Record<string, unknown>;
1019
+ /**
1020
+ * Register (or upsert) a token→identity entry in the family's shared
1021
+ * registry. Siblings already bound to `token` resolve lazily, so a
1022
+ * re-registration takes effect on them immediately. Registering an
1023
+ * identity does not bind it — pass `token` at `createTestClient` or
1024
+ * `withConfig({ token })` to act as it.
1025
+ */
1026
+ addIdentity: (token: string, identity: TestClientIdentity) => void;
1027
+ /** Snapshot of the registered token→identity entries. */
1028
+ identities: () => Record<string, TestClientIdentity>;
887
1029
  /**
888
1030
  * Every mutation committed through this client's registry, flattened
889
1031
  * and in order — `create` / `createOrReplace` / `createIfNotExists` /
@@ -928,6 +1070,20 @@ declare interface TestClientAccessControl {
928
1070
  aclPath?: string;
929
1071
  }
930
1072
 
1073
+ /**
1074
+ * One entry in the token→identity registry (`createTestClient({ identities })`).
1075
+ * A handle bound to the entry's token behaves exactly like one configured
1076
+ * with `accessControl: { currentUser: user, grants }` — `/users/me` serves
1077
+ * `user`, the default ACL path serves `grants`, and reads/writes are gated
1078
+ * through them.
1079
+ */
1080
+ declare interface TestClientIdentity {
1081
+ /** What `/users/me` returns for clients bound to this token. */
1082
+ user: CurrentUser;
1083
+ /** Grants served at the ACL path and enforced on reads/writes. */
1084
+ grants: Grant_2[];
1085
+ }
1086
+
931
1087
  /**
932
1088
  * In-memory document store backing a TestClient. Closure-scoped — created
933
1089
  * fresh per test, no global state.
@@ -983,6 +1139,12 @@ declare interface TxnCommitOptions {
983
1139
  returnFirst?: boolean;
984
1140
  /** Override the synthetic transaction id (mirrors `client.transaction(undefined, { transactionId })`). */
985
1141
  transactionId?: string;
1142
+ /**
1143
+ * Request tag, recorded on the mutation log entry for assertions.
1144
+ * `null` mirrors `@sanity/client`'s explicit-suppression form and is
1145
+ * recorded as-is. No other behavior is simulated.
1146
+ */
1147
+ tag?: string | null;
986
1148
  }
987
1149
 
988
1150
  declare type TxnCommitResult =