@atproto/ozone 0.1.5 → 0.1.7

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 (80) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/api/communication/createTemplate.js +2 -2
  3. package/dist/api/communication/createTemplate.js.map +1 -1
  4. package/dist/api/communication/deleteTemplate.js +2 -2
  5. package/dist/api/communication/deleteTemplate.js.map +1 -1
  6. package/dist/api/communication/updateTemplate.js +2 -2
  7. package/dist/api/communication/updateTemplate.js.map +1 -1
  8. package/dist/api/proxied.d.ts.map +1 -1
  9. package/dist/api/proxied.js +10 -0
  10. package/dist/api/proxied.js.map +1 -1
  11. package/dist/auth-verifier.d.ts.map +1 -1
  12. package/dist/auth-verifier.js.map +1 -1
  13. package/dist/context.d.ts.map +1 -1
  14. package/dist/context.js.map +1 -1
  15. package/dist/db/pagination.d.ts +5 -5
  16. package/dist/db/pagination.d.ts.map +1 -1
  17. package/dist/db/pagination.js.map +1 -1
  18. package/dist/db/types.d.ts.map +1 -1
  19. package/dist/lexicon/index.d.ts +16 -0
  20. package/dist/lexicon/index.d.ts.map +1 -1
  21. package/dist/lexicon/index.js +19 -1
  22. package/dist/lexicon/index.js.map +1 -1
  23. package/dist/lexicon/lexicons.d.ts +244 -0
  24. package/dist/lexicon/lexicons.d.ts.map +1 -1
  25. package/dist/lexicon/lexicons.js +258 -1
  26. package/dist/lexicon/lexicons.js.map +1 -1
  27. package/dist/lexicon/types/app/bsky/embed/record.d.ts +3 -0
  28. package/dist/lexicon/types/app/bsky/embed/record.d.ts.map +1 -1
  29. package/dist/lexicon/types/app/bsky/embed/record.js.map +1 -1
  30. package/dist/lexicon/types/app/bsky/feed/defs.d.ts +38 -0
  31. package/dist/lexicon/types/app/bsky/feed/defs.d.ts.map +1 -1
  32. package/dist/lexicon/types/app/bsky/feed/defs.js +35 -1
  33. package/dist/lexicon/types/app/bsky/feed/defs.js.map +1 -1
  34. package/dist/lexicon/types/app/bsky/feed/generator.d.ts +2 -0
  35. package/dist/lexicon/types/app/bsky/feed/generator.d.ts.map +1 -1
  36. package/dist/lexicon/types/app/bsky/feed/generator.js.map +1 -1
  37. package/dist/lexicon/types/app/bsky/feed/searchPosts.d.ts +18 -0
  38. package/dist/lexicon/types/app/bsky/feed/searchPosts.d.ts.map +1 -1
  39. package/dist/lexicon/types/app/bsky/feed/sendInteractions.d.ts +40 -0
  40. package/dist/lexicon/types/app/bsky/feed/sendInteractions.d.ts.map +1 -0
  41. package/dist/lexicon/types/app/bsky/feed/sendInteractions.js +3 -0
  42. package/dist/lexicon/types/app/bsky/feed/sendInteractions.js.map +1 -0
  43. package/dist/lexicon/types/app/bsky/unspecced/searchActorsSkeleton.d.ts +2 -0
  44. package/dist/lexicon/types/app/bsky/unspecced/searchActorsSkeleton.d.ts.map +1 -1
  45. package/dist/lexicon/types/app/bsky/unspecced/searchPostsSkeleton.d.ts +20 -0
  46. package/dist/lexicon/types/app/bsky/unspecced/searchPostsSkeleton.d.ts.map +1 -1
  47. package/dist/lexicon/types/com/atproto/sync/getRecord.d.ts +1 -1
  48. package/dist/lexicon/types/com/atproto/sync/getRecord.d.ts.map +1 -1
  49. package/dist/mod-service/index.d.ts.map +1 -1
  50. package/dist/mod-service/index.js +2 -2
  51. package/dist/mod-service/index.js.map +1 -1
  52. package/dist/sequencer/outbox.d.ts.map +1 -1
  53. package/dist/sequencer/outbox.js.map +1 -1
  54. package/dist/sequencer/sequencer.d.ts.map +1 -1
  55. package/dist/sequencer/sequencer.js.map +1 -1
  56. package/package.json +5 -5
  57. package/src/api/communication/createTemplate.ts +2 -2
  58. package/src/api/communication/deleteTemplate.ts +2 -2
  59. package/src/api/communication/updateTemplate.ts +2 -2
  60. package/src/api/proxied.ts +14 -0
  61. package/src/auth-verifier.ts +4 -1
  62. package/src/context.ts +4 -1
  63. package/src/db/pagination.ts +4 -1
  64. package/src/lexicon/index.ts +26 -0
  65. package/src/lexicon/lexicons.ts +285 -1
  66. package/src/lexicon/types/app/bsky/embed/record.ts +3 -0
  67. package/src/lexicon/types/app/bsky/feed/defs.ts +63 -0
  68. package/src/lexicon/types/app/bsky/feed/generator.ts +2 -0
  69. package/src/lexicon/types/app/bsky/feed/searchPosts.ts +18 -0
  70. package/src/lexicon/types/app/bsky/feed/sendInteractions.ts +49 -0
  71. package/src/lexicon/types/app/bsky/unspecced/searchActorsSkeleton.ts +2 -0
  72. package/src/lexicon/types/app/bsky/unspecced/searchPostsSkeleton.ts +20 -0
  73. package/src/lexicon/types/com/atproto/sync/getRecord.ts +1 -1
  74. package/src/mod-service/index.ts +6 -5
  75. package/src/sequencer/outbox.ts +4 -1
  76. package/src/sequencer/sequencer.ts +4 -1
  77. package/tests/_util.ts +4 -4
  78. package/tests/communication-templates.test.ts +5 -5
  79. package/tests/moderation-statuses.test.ts +27 -0
  80. package/tests/repo-search.test.ts +1 -1
@@ -69,6 +69,8 @@ export interface FeedViewPost {
69
69
  post: PostView
70
70
  reply?: ReplyRef
71
71
  reason?: ReasonRepost | { $type: string; [k: string]: unknown }
72
+ /** Context provided by feed generator that may be passed back alongside interactions. */
73
+ feedContext?: string
72
74
  [k: string]: unknown
73
75
  }
74
76
 
@@ -219,6 +221,7 @@ export interface GeneratorView {
219
221
  descriptionFacets?: AppBskyRichtextFacet.Main[]
220
222
  avatar?: string
221
223
  likeCount?: number
224
+ acceptsInteractions?: boolean
222
225
  labels?: ComAtprotoLabelDefs.Label[]
223
226
  viewer?: GeneratorViewerState
224
227
  indexedAt: string
@@ -257,6 +260,8 @@ export function validateGeneratorViewerState(v: unknown): ValidationResult {
257
260
  export interface SkeletonFeedPost {
258
261
  post: string
259
262
  reason?: SkeletonReasonRepost | { $type: string; [k: string]: unknown }
263
+ /** Context that will be passed through to client and may be passed to feed generator back alongside interactions. */
264
+ feedContext?: string
260
265
  [k: string]: unknown
261
266
  }
262
267
 
@@ -308,3 +313,61 @@ export function isThreadgateView(v: unknown): v is ThreadgateView {
308
313
  export function validateThreadgateView(v: unknown): ValidationResult {
309
314
  return lexicons.validate('app.bsky.feed.defs#threadgateView', v)
310
315
  }
316
+
317
+ export interface Interaction {
318
+ item?: string
319
+ event?:
320
+ | 'app.bsky.feed.defs#requestLess'
321
+ | 'app.bsky.feed.defs#requestMore'
322
+ | 'app.bsky.feed.defs#clickthroughItem'
323
+ | 'app.bsky.feed.defs#clickthroughAuthor'
324
+ | 'app.bsky.feed.defs#clickthroughReposter'
325
+ | 'app.bsky.feed.defs#clickthroughEmbed'
326
+ | 'app.bsky.feed.defs#interactionSeen'
327
+ | 'app.bsky.feed.defs#interactionLike'
328
+ | 'app.bsky.feed.defs#interactionRepost'
329
+ | 'app.bsky.feed.defs#interactionReply'
330
+ | 'app.bsky.feed.defs#interactionQuote'
331
+ | 'app.bsky.feed.defs#interactionShare'
332
+ | (string & {})
333
+ /** Context on a feed item that was orginally supplied by the feed generator on getFeedSkeleton. */
334
+ feedContext?: string
335
+ [k: string]: unknown
336
+ }
337
+
338
+ export function isInteraction(v: unknown): v is Interaction {
339
+ return (
340
+ isObj(v) &&
341
+ hasProp(v, '$type') &&
342
+ v.$type === 'app.bsky.feed.defs#interaction'
343
+ )
344
+ }
345
+
346
+ export function validateInteraction(v: unknown): ValidationResult {
347
+ return lexicons.validate('app.bsky.feed.defs#interaction', v)
348
+ }
349
+
350
+ /** Request that less content like the given feed item be shown in the feed */
351
+ export const REQUESTLESS = 'app.bsky.feed.defs#requestLess'
352
+ /** Request that more content like the given feed item be shown in the feed */
353
+ export const REQUESTMORE = 'app.bsky.feed.defs#requestMore'
354
+ /** User clicked through to the feed item */
355
+ export const CLICKTHROUGHITEM = 'app.bsky.feed.defs#clickthroughItem'
356
+ /** User clicked through to the author of the feed item */
357
+ export const CLICKTHROUGHAUTHOR = 'app.bsky.feed.defs#clickthroughAuthor'
358
+ /** User clicked through to the reposter of the feed item */
359
+ export const CLICKTHROUGHREPOSTER = 'app.bsky.feed.defs#clickthroughReposter'
360
+ /** User clicked through to the embedded content of the feed item */
361
+ export const CLICKTHROUGHEMBED = 'app.bsky.feed.defs#clickthroughEmbed'
362
+ /** Feed item was seen by user */
363
+ export const INTERACTIONSEEN = 'app.bsky.feed.defs#interactionSeen'
364
+ /** User liked the feed item */
365
+ export const INTERACTIONLIKE = 'app.bsky.feed.defs#interactionLike'
366
+ /** User reposted the feed item */
367
+ export const INTERACTIONREPOST = 'app.bsky.feed.defs#interactionRepost'
368
+ /** User replied to the feed item */
369
+ export const INTERACTIONREPLY = 'app.bsky.feed.defs#interactionReply'
370
+ /** User quoted the feed item */
371
+ export const INTERACTIONQUOTE = 'app.bsky.feed.defs#interactionQuote'
372
+ /** User shared the feed item */
373
+ export const INTERACTIONSHARE = 'app.bsky.feed.defs#interactionShare'
@@ -14,6 +14,8 @@ export interface Record {
14
14
  description?: string
15
15
  descriptionFacets?: AppBskyRichtextFacet.Main[]
16
16
  avatar?: BlobRef
17
+ /** Declaration that a feed accepts feedback interactions from a client through app.bsky.feed.sendInteractions */
18
+ acceptsInteractions?: boolean
17
19
  labels?:
18
20
  | ComAtprotoLabelDefs.SelfLabels
19
21
  | { $type: string; [k: string]: unknown }
@@ -12,6 +12,24 @@ import * as AppBskyFeedDefs from './defs'
12
12
  export interface QueryParams {
13
13
  /** Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended. */
14
14
  q: string
15
+ /** Specifies the ranking order of results. */
16
+ sort: 'top' | 'latest' | (string & {})
17
+ /** Filter results for posts after the indicated datetime (inclusive). Expected to use 'sortAt' timestamp, which may not match 'createdAt'. Can be a datetime, or just an ISO date (YYYY-MM-DD). */
18
+ since?: string
19
+ /** Filter results for posts before the indicated datetime (not inclusive). Expected to use 'sortAt' timestamp, which may not match 'createdAt'. Can be a datetime, or just an ISO date (YYY-MM-DD). */
20
+ until?: string
21
+ /** Filter to posts which mention the given account. Handles are resolved to DID before query-time. Only matches rich-text facet mentions. */
22
+ mentions?: string
23
+ /** Filter to posts by the given account. Handles are resolved to DID before query-time. */
24
+ author?: string
25
+ /** Filter to posts in the given language. Expected to be based on post language field, though server may override language detection. */
26
+ lang?: string
27
+ /** Filter to posts with URLs (facet links or embeds) linking to the given domain (hostname). Server may apply hostname normalization. */
28
+ domain?: string
29
+ /** Filter to posts with links (facet links or embeds) pointing to this URL. Server may apply URL normalization or fuzzy matching. */
30
+ url?: string
31
+ /** Filter to posts with the given tag (hashtag), based on rich-text facet or tag field. Do not include the hash (#) prefix. Multiple tags can be specified, with 'AND' matching. */
32
+ tag?: string[]
15
33
  limit: number
16
34
  /** Optional pagination mechanism; may not necessarily allow scrolling through entire result set. */
17
35
  cursor?: string
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GENERATED CODE - DO NOT MODIFY
3
+ */
4
+ import express from 'express'
5
+ import { ValidationResult, BlobRef } from '@atproto/lexicon'
6
+ import { lexicons } from '../../../../lexicons'
7
+ import { isObj, hasProp } from '../../../../util'
8
+ import { CID } from 'multiformats/cid'
9
+ import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
10
+ import * as AppBskyFeedDefs from './defs'
11
+
12
+ export interface QueryParams {}
13
+
14
+ export interface InputSchema {
15
+ interactions: AppBskyFeedDefs.Interaction[]
16
+ [k: string]: unknown
17
+ }
18
+
19
+ export interface OutputSchema {
20
+ [k: string]: unknown
21
+ }
22
+
23
+ export interface HandlerInput {
24
+ encoding: 'application/json'
25
+ body: InputSchema
26
+ }
27
+
28
+ export interface HandlerSuccess {
29
+ encoding: 'application/json'
30
+ body: OutputSchema
31
+ headers?: { [key: string]: string }
32
+ }
33
+
34
+ export interface HandlerError {
35
+ status: number
36
+ message?: string
37
+ }
38
+
39
+ export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
40
+ export type HandlerReqCtx<HA extends HandlerAuth = never> = {
41
+ auth: HA
42
+ params: QueryParams
43
+ input: HandlerInput
44
+ req: express.Request
45
+ res: express.Response
46
+ }
47
+ export type Handler<HA extends HandlerAuth = never> = (
48
+ ctx: HandlerReqCtx<HA>,
49
+ ) => Promise<HandlerOutput> | HandlerOutput
@@ -12,6 +12,8 @@ import * as AppBskyUnspeccedDefs from './defs'
12
12
  export interface QueryParams {
13
13
  /** Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended. For typeahead search, only simple term match is supported, not full syntax. */
14
14
  q: string
15
+ /** DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking. */
16
+ viewer?: string
15
17
  /** If true, acts as fast/simple 'typeahead' query. */
16
18
  typeahead?: boolean
17
19
  limit: number
@@ -12,6 +12,26 @@ import * as AppBskyUnspeccedDefs from './defs'
12
12
  export interface QueryParams {
13
13
  /** Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended. */
14
14
  q: string
15
+ /** Specifies the ranking order of results. */
16
+ sort: 'top' | 'latest' | (string & {})
17
+ /** Filter results for posts after the indicated datetime (inclusive). Expected to use 'sortAt' timestamp, which may not match 'createdAt'. Can be a datetime, or just an ISO date (YYYY-MM-DD). */
18
+ since?: string
19
+ /** Filter results for posts before the indicated datetime (not inclusive). Expected to use 'sortAt' timestamp, which may not match 'createdAt'. Can be a datetime, or just an ISO date (YYY-MM-DD). */
20
+ until?: string
21
+ /** Filter to posts which mention the given account. Handles are resolved to DID before query-time. Only matches rich-text facet mentions. */
22
+ mentions?: string
23
+ /** Filter to posts by the given account. Handles are resolved to DID before query-time. */
24
+ author?: string
25
+ /** Filter to posts in the given language. Expected to be based on post language field, though server may override language detection. */
26
+ lang?: string
27
+ /** Filter to posts with URLs (facet links or embeds) linking to the given domain (hostname). Server may apply hostname normalization. */
28
+ domain?: string
29
+ /** Filter to posts with links (facet links or embeds) pointing to this URL. Server may apply URL normalization or fuzzy matching. */
30
+ url?: string
31
+ /** Filter to posts with the given tag (hashtag), based on rich-text facet or tag field. Do not include the hash (#) prefix. Multiple tags can be specified, with 'AND' matching. */
32
+ tag?: string[]
33
+ /** DID of the account making the request (not included for public/unauthenticated queries). Used for 'from:me' queries. */
34
+ viewer?: string
15
35
  limit: number
16
36
  /** Optional pagination mechanism; may not necessarily allow scrolling through entire result set. */
17
37
  cursor?: string
@@ -15,7 +15,7 @@ export interface QueryParams {
15
15
  collection: string
16
16
  /** Record Key */
17
17
  rkey: string
18
- /** An optional past commit CID. */
18
+ /** DEPRECATED: referenced a repo commit by CID, and retrieved record as of that commit */
19
19
  commit?: string
20
20
  }
21
21
 
@@ -759,7 +759,9 @@ export class ModerationService {
759
759
 
760
760
  if (tags.length) {
761
761
  builder = builder.where(
762
- sql`${ref('moderation_subject_status.tags')} @> ${jsonb(tags)}`,
762
+ sql`${ref('moderation_subject_status.tags')} ?| array[${sql.join(
763
+ tags,
764
+ )}]::TEXT[]`,
763
765
  )
764
766
  }
765
767
 
@@ -767,9 +769,9 @@ export class ModerationService {
767
769
  builder = builder.where((qb) =>
768
770
  qb
769
771
  .where(
770
- sql`NOT(${ref('moderation_subject_status.tags')} @> ${jsonb(
771
- excludeTags,
772
- )})`,
772
+ sql`NOT(${ref(
773
+ 'moderation_subject_status.tags',
774
+ )} ?| array[${sql.join(excludeTags)}]::TEXT[])`,
773
775
  )
774
776
  .orWhere('tags', 'is', null),
775
777
  )
@@ -787,7 +789,6 @@ export class ModerationService {
787
789
  tryIndex: true,
788
790
  nullsLast: true,
789
791
  })
790
-
791
792
  const results = await paginatedBuilder.execute()
792
793
 
793
794
  const infos = await this.views.getAccoutInfosByDid(
@@ -13,7 +13,10 @@ export class Outbox {
13
13
  cutoverBuffer: LabelsEvt[]
14
14
  outBuffer: AsyncBuffer<LabelsEvt>
15
15
 
16
- constructor(public sequencer: Sequencer, opts: Partial<OutboxOpts> = {}) {
16
+ constructor(
17
+ public sequencer: Sequencer,
18
+ opts: Partial<OutboxOpts> = {},
19
+ ) {
17
20
  const { maxBufferSize = 500 } = opts
18
21
  this.cutoverBuffer = []
19
22
  this.outBuffer = new AsyncBuffer<LabelsEvt>(maxBufferSize)
@@ -18,7 +18,10 @@ export class Sequencer extends (EventEmitter as new () => SequencerEmitter) {
18
18
  queued = false
19
19
  conn: PoolClient | undefined
20
20
 
21
- constructor(public modSrvc: ModerationService, public lastSeen = 0) {
21
+ constructor(
22
+ public modSrvc: ModerationService,
23
+ public lastSeen = 0,
24
+ ) {
22
25
  super()
23
26
  this.db = modSrvc.db
24
27
  // note: this does not err when surpassed, just prints a warning to stderr
package/tests/_util.ts CHANGED
@@ -164,16 +164,16 @@ export const stripViewerFromPost = (postUnknown: unknown): PostView => {
164
164
  post.embed && isViewRecord(post.embed.record)
165
165
  ? post.embed.record // Record from record embed
166
166
  : post.embed?.['record'] && isViewRecord(post.embed['record']['record'])
167
- ? post.embed['record']['record'] // Record from record-with-media embed
168
- : undefined
167
+ ? post.embed['record']['record'] // Record from record-with-media embed
168
+ : undefined
169
169
  if (recordEmbed) {
170
170
  recordEmbed.author = stripViewer(recordEmbed.author)
171
171
  recordEmbed.embeds?.forEach((deepEmbed) => {
172
172
  const deepRecordEmbed = isViewRecord(deepEmbed.record)
173
173
  ? deepEmbed.record // Record from record embed
174
174
  : deepEmbed['record'] && isViewRecord(deepEmbed['record']['record'])
175
- ? deepEmbed['record']['record'] // Record from record-with-media embed
176
- : undefined
175
+ ? deepEmbed['record']['record'] // Record from record-with-media embed
176
+ : undefined
177
177
  if (deepRecordEmbed) {
178
178
  deepRecordEmbed.author = stripViewer(deepRecordEmbed.author)
179
179
  }
@@ -42,11 +42,11 @@ describe('communication-templates', () => {
42
42
  { ...templateOne, createdBy: sc.dids.bob },
43
43
  {
44
44
  encoding: 'application/json',
45
- headers: await network.ozone.modHeaders('moderator'),
45
+ headers: await network.ozone.modHeaders('triage'),
46
46
  },
47
47
  )
48
48
  await expect(moderatorReq).rejects.toThrow(
49
- 'Must be an admin to create a communication template',
49
+ 'Must be a moderator to create a communication template',
50
50
  )
51
51
  const modReq = await agent.api.tools.ozone.communication.createTemplate(
52
52
  { ...templateOne, createdBy: sc.dids.bob },
@@ -105,19 +105,19 @@ describe('communication-templates', () => {
105
105
  { id: '1' },
106
106
  {
107
107
  encoding: 'application/json',
108
- headers: await network.ozone.modHeaders('moderator'),
108
+ headers: await network.ozone.modHeaders('triage'),
109
109
  },
110
110
  )
111
111
 
112
112
  await expect(modReq).rejects.toThrow(
113
- 'Must be an admin to delete a communication template',
113
+ 'Must be a moderator to delete a communication template',
114
114
  )
115
115
 
116
116
  await agent.api.tools.ozone.communication.deleteTemplate(
117
117
  { id: '1' },
118
118
  {
119
119
  encoding: 'application/json',
120
- headers: await network.ozone.modHeaders('admin'),
120
+ headers: await network.ozone.modHeaders('moderator'),
121
121
  },
122
122
  )
123
123
  const list = await listTemplates()
@@ -99,6 +99,33 @@ describe('moderation-statuses', () => {
99
99
  expect(nonKlingonQueue.subjectStatuses.map((s) => s.id)).not.toContain(
100
100
  klingonQueue.subjectStatuses[0].id,
101
101
  )
102
+
103
+ // Verify multi lang tag exclusion
104
+ Promise.all(
105
+ nonKlingonQueue.subjectStatuses.map((s, i) => {
106
+ return modClient.emitEvent({
107
+ subject: s.subject,
108
+ event: {
109
+ $type: 'tools.ozone.moderation.defs#modEventTag',
110
+ add: [i % 2 ? 'lang:jp' : 'lang:it'],
111
+ remove: [],
112
+ comment: 'Adding custom lang tag',
113
+ },
114
+ createdBy: sc.dids.alice,
115
+ })
116
+ }),
117
+ )
118
+
119
+ const queueWithoutKlingonAndItalian = await modClient.queryStatuses({
120
+ excludeTags: ['lang:i', 'lang:it'],
121
+ })
122
+
123
+ queueWithoutKlingonAndItalian.subjectStatuses
124
+ .map((s) => s.tags)
125
+ .flat()
126
+ .forEach((tag) => {
127
+ expect(['lang:it', 'lang:i']).not.toContain(tag)
128
+ })
102
129
  })
103
130
 
104
131
  it('returns paginated statuses', async () => {
@@ -52,7 +52,7 @@ describe('admin repo search view', () => {
52
52
  const shouldContain = [
53
53
  'cara-wiegand69.test', // Present despite repo takedown
54
54
  'carlos6.test',
55
- 'carolina-mcdermott77.test',
55
+ 'carolina-mcderm77.test',
56
56
  ]
57
57
 
58
58
  shouldContain.forEach((handle) => expect(handles).toContain(handle))