@toa.io/core 1.0.0-alpha.27 → 1.0.0-alpha.270

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 (63) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/package.json +7 -9
  3. package/src/call.js +21 -1
  4. package/src/cascade.js +7 -5
  5. package/src/component.js +59 -15
  6. package/src/composition.js +1 -1
  7. package/src/connector.js +28 -17
  8. package/src/context.js +6 -0
  9. package/src/contract/contract.js +22 -0
  10. package/src/contract/reply.js +26 -9
  11. package/src/contract/request.js +19 -5
  12. package/src/contract/schemas/index.js +1 -0
  13. package/src/contract/schemas/query.yaml +14 -1
  14. package/src/contract/schemas/source.yaml +23 -0
  15. package/src/discovery.js +13 -7
  16. package/src/effect.js +19 -0
  17. package/src/entities/changeset.js +5 -8
  18. package/src/entities/entity.js +76 -24
  19. package/src/entities/factory.js +17 -6
  20. package/src/entities/newid.js +11 -0
  21. package/src/entities/set.js +13 -0
  22. package/src/event.js +19 -1
  23. package/src/exceptions.js +26 -19
  24. package/src/exposition.js +3 -2
  25. package/src/guard.js +17 -0
  26. package/src/index.js +6 -0
  27. package/src/observation.js +1 -9
  28. package/src/operation.js +38 -8
  29. package/src/query/options.js +3 -2
  30. package/src/query.js +32 -2
  31. package/src/receiver.js +73 -11
  32. package/src/remote.js +8 -7
  33. package/src/state.js +74 -35
  34. package/src/transition.js +11 -19
  35. package/src/transmission.js +12 -3
  36. package/src/unmanaged.js +11 -0
  37. package/test/component.test.js +4 -3
  38. package/test/connector.fixtures.js +8 -0
  39. package/test/connector.test.js +20 -0
  40. package/test/contract/conditions.test.js +5 -5
  41. package/test/contract/request.test.js +46 -7
  42. package/test/discovery.test.js +56 -0
  43. package/test/entities/entity.test.js +54 -40
  44. package/test/entities/factory.test.js +5 -3
  45. package/test/event.test.js +4 -4
  46. package/test/query.test.js +17 -0
  47. package/test/receiver.fixtures.js +1 -0
  48. package/test/state.test.js +13 -12
  49. package/types/bindings.d.ts +7 -5
  50. package/types/bridges.ts +3 -0
  51. package/types/component.d.ts +4 -1
  52. package/types/context.d.ts +3 -0
  53. package/types/entity.d.ts +2 -2
  54. package/types/extensions.d.ts +7 -4
  55. package/types/index.ts +2 -1
  56. package/types/message.d.ts +1 -0
  57. package/types/operations.d.ts +6 -0
  58. package/types/query.d.ts +2 -0
  59. package/types/remote.d.ts +18 -0
  60. package/types/request.d.ts +15 -0
  61. package/types/state.d.ts +2 -2
  62. package/types/storages.d.ts +11 -9
  63. package/src/contract/conditions.js +0 -21
package/src/receiver.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { console, decode, run } = require('openspan')
3
4
  const { add } = require('@toa.io/generic')
4
5
  const { Connector } = require('./connector')
5
6
 
@@ -16,18 +17,28 @@ class Receiver extends Connector {
16
17
  /** @type {string} */
17
18
  #endpoint
18
19
 
19
- /** @type {toa.core.Component} */
20
+ /** @type {string} */
21
+ #label
22
+
23
+ /** @type {string} */
24
+ #destination
25
+
26
+ /** @type {unknown[]} */
27
+ #arguments
28
+
29
+ /** @type {toa.core.Source} */
30
+ #origin
31
+
20
32
  #local
21
33
 
22
- /** @type {toa.core.bridges.Receiver} */
23
34
  #bridge
24
35
 
25
- /**
26
- *
27
- * @param {toa.norm.component.Receiver} definition
28
- * @param {toa.core.Component} local
29
- * @param {toa.core.bridges.Receiver} bridge
30
- */
36
+ /** @type {object} */
37
+ #delivery
38
+
39
+ /** @type {object} */
40
+ #processing
41
+
31
42
  constructor (definition, local, bridge) {
32
43
  super()
33
44
 
@@ -36,17 +47,42 @@ class Receiver extends Connector {
36
47
  this.#conditioned = conditioned
37
48
  this.#adaptive = adaptive
38
49
  this.#endpoint = operation
50
+ this.#label = definition.label ?? operation
51
+ this.#destination = definition.destination ?? this.#label
52
+ this.#arguments = definition.arguments
53
+ this.#origin = definition.origin
39
54
 
40
55
  this.#local = local
41
56
  this.#bridge = bridge
42
57
 
43
58
  this.depends(local)
44
59
  if (bridge !== undefined) this.depends(bridge)
60
+
61
+ /*
62
+ * The delivery span is created on behalf of the messaging destination, so that
63
+ * each consumer forms its own complete producer/consumer pair, and service graphs
64
+ * display fan-out correctly: producer -> destination -> each consumer
65
+ * (Tempo pairs spans one-to-one, thus multiple consumers can not pair
66
+ * with a single producer span, see grafana/tempo#5408)
67
+ */
68
+ this.#delivery = {
69
+ name: `${this.#label} deliver`,
70
+ kind: 'producer',
71
+ service: this.#destination,
72
+ attributes: { 'messaging.destination.name': this.#destination }
73
+ }
74
+
75
+ this.#processing = {
76
+ name: `${this.#label} process`,
77
+ kind: 'consumer',
78
+ service: local.locator.id,
79
+ attributes: { 'messaging.destination.name': this.#destination }
80
+ }
45
81
  }
46
82
 
47
83
  /** @hot */
48
84
  async receive (message) {
49
- const { payload, ...extensions } = message
85
+ const { payload, telemetry, ...extensions } = message
50
86
 
51
87
  if (this.#conditioned && await this.#bridge.condition(payload) === false) return
52
88
 
@@ -54,11 +90,37 @@ class Receiver extends Connector {
54
90
 
55
91
  add(request, extensions)
56
92
 
57
- await this.#local.invoke(this.#endpoint, request)
93
+ // set after `add`, so that a message field can not spoof the origin
94
+ if (this.#origin !== undefined) request.source = this.#origin
95
+
96
+ // continue the trace from the producer span
97
+ const remote = telemetry === undefined ? null : decode(telemetry)
98
+ const task = () => this.#process(request)
99
+
100
+ if (remote === null)
101
+ await task()
102
+ else
103
+ await run(remote, task)
104
+ }
105
+
106
+ async #process (request) {
107
+ return console.span(this.#delivery, async () => console.span(this.#processing, async () => {
108
+ try {
109
+ await this.#local.invoke(this.#endpoint, request)
110
+ } catch (error) {
111
+ console.error('Receiver error', {
112
+ component: this.#local.locator.id,
113
+ endpoint: this.#endpoint,
114
+ error
115
+ })
116
+
117
+ throw error
118
+ }
119
+ }))
58
120
  }
59
121
 
60
122
  async #request (payload) {
61
- return this.#adaptive ? await this.#bridge.request(payload) : { input: payload }
123
+ return this.#adaptive ? await this.#bridge.request(payload, ...(this.#arguments ?? [])) : { input: payload }
62
124
  }
63
125
  }
64
126
 
package/src/remote.js CHANGED
@@ -1,16 +1,17 @@
1
1
  'use strict'
2
2
 
3
- const { console } = require('@toa.io/console')
4
-
3
+ const assert = require('node:assert')
5
4
  const { Component } = require('./component')
6
5
 
7
6
  class Remote extends Component {
8
- async open () {
9
- console.info(`Remote '${this.locator.id}' connected`)
10
- }
7
+ kind = 'client'
8
+
9
+ explain (endpoint) {
10
+ if (!(endpoint in this.operations))
11
+ // `assert.fail`, not `assert.ok`: the message is built only when it is needed
12
+ assert.fail(`Endpoint '${endpoint}' is not provided by '${this.locator.id}'`)
11
13
 
12
- async dispose () {
13
- console.info(`Remote '${this.locator.id}' disconnected`)
14
+ return this.operations[endpoint].explain()
14
15
  }
15
16
  }
16
17
 
package/src/state.js CHANGED
@@ -1,73 +1,109 @@
1
1
  'use strict'
2
2
 
3
- const { empty } = require('@toa.io/generic')
4
-
5
- const {
6
- StatePreconditionException,
7
- StateNotFoundException
8
- } = require('./exceptions')
3
+ const { StatePreconditionException, StateNotFoundException } = require('./exceptions')
9
4
 
10
5
  class State {
6
+ storage
7
+
11
8
  #associated
12
- #storage
13
- #entity
9
+ #entities
14
10
  #emission
15
11
 
16
12
  constructor (storage, entity, emission, associated) {
17
- this.#storage = storage
18
- this.#entity = entity
13
+ this.storage = storage
14
+ this.#entities = entity
19
15
  this.#emission = emission
20
16
  this.#associated = associated === true
21
17
  }
22
18
 
23
19
  init (id) {
24
- return this.#entity.init(id)
20
+ return this.#entities.init(id)
21
+ }
22
+
23
+ fit (values) {
24
+ return this.#entities.fit(values)
25
25
  }
26
26
 
27
- async object (query) {
28
- const record = await this.#storage.get(query)
27
+ async object (query, mutable = true) {
28
+ const record = await this.storage.get(query)
29
29
 
30
30
  if (record === null) {
31
- if (this.#associated && query.id !== undefined && query.version === undefined) {
31
+ if (this.#associated && query.id !== undefined && query.criteria === undefined && query.version === undefined)
32
32
  return this.init(query.id)
33
- } else if (query.version !== undefined) throw new StatePreconditionException()
34
- }
33
+ else if (query.version !== undefined)
34
+ throw new StatePreconditionException()
35
35
 
36
- if (record === null) {
37
36
  return null
38
- } else {
39
- return this.#entity.object(record)
40
- }
37
+ } else
38
+ return this.#entities.object(record, mutable)
41
39
  }
42
40
 
43
- async objects (query) {
44
- const recordset = await this.#storage.find(query)
41
+ async objects (query, mutable = true) {
42
+ const recordset = await this.storage.find(query)
43
+ const missing = this.#associated && query.ids !== undefined && recordset.length < query.ids.length
44
+ const init = missing ? query.ids.filter((id) => !recordset.some((record) => record.id === id)) : undefined
45
+
46
+ return this.#entities.objects(recordset, init, mutable)
47
+ }
45
48
 
46
- return this.#entity.objects(recordset)
49
+ async stream (query) {
50
+ return this.storage.stream(query)
47
51
  }
48
52
 
49
53
  changeset (query) {
50
- return this.#entity.changeset(query)
54
+ return this.#entities.changeset(query)
51
55
  }
52
56
 
53
57
  none () {
54
58
  return null
55
59
  }
56
60
 
57
- async commit (state) {
58
- const event = state.event()
61
+ async ensure (query, properties, input) {
62
+ const object = this.#entities.init()
63
+ const blank = object.get()
59
64
 
60
- let ok = true
65
+ Object.assign(blank, properties)
61
66
 
62
- if (!empty(event.changeset)) {
63
- const object = state.get()
67
+ object.set(blank)
64
68
 
65
- ok = await this.#storage.store(object)
69
+ const record = await this.storage.ensure(query, properties, object.get())
66
70
 
67
- // #20
68
- if (ok === true) {
69
- await this.#emission.emit(event)
70
- }
71
+ if (record.id !== blank.id) // exists
72
+ return this.#entities.object(record, NOT_MUTABLE)
73
+
74
+ const event = object.event(input)
75
+
76
+ await this.#emission.emit(event)
77
+
78
+ return object
79
+ }
80
+
81
+ async commit (state, input) {
82
+ if (state.constructor.name === 'EntitySet')
83
+ return this.massCommit(state, input)
84
+
85
+ const data = state.get()
86
+ const ok = await this.storage.store(data)
87
+
88
+ // #20
89
+ if (ok === true) {
90
+ const event = state.event(input)
91
+
92
+ await this.#emission.emit(event)
93
+ }
94
+
95
+ return ok
96
+ }
97
+
98
+ async massCommit (state, input) {
99
+ const data = state.get()
100
+ const ok = await this.storage.massStore(data)
101
+
102
+ // #20
103
+ if (ok === true) {
104
+ const events = state.events(input)
105
+
106
+ await Promise.all(events.map((event) => this.#emission.emit(event)))
71
107
  }
72
108
 
73
109
  return ok
@@ -76,7 +112,7 @@ class State {
76
112
  async apply (state) {
77
113
  const changeset = state.export()
78
114
 
79
- const result = await this.#storage.upsert(state.query, changeset)
115
+ const result = await this.storage.upsert(state.query, changeset)
80
116
 
81
117
  if (result === null) {
82
118
  if (state.query.version !== undefined) {
@@ -96,4 +132,7 @@ class State {
96
132
  }
97
133
  }
98
134
 
135
+ /** an effect never commits what `ensure` hands it, see `Effect` */
136
+ const NOT_MUTABLE = false
137
+
99
138
  exports.State = State
package/src/transition.js CHANGED
@@ -1,14 +1,13 @@
1
1
  'use strict'
2
2
 
3
3
  const { retry } = require('@toa.io/generic')
4
-
5
4
  const { Operation } = require('./operation')
6
- const {
7
- StateConcurrencyException,
8
- StateNotFoundException
9
- } = require('./exceptions')
5
+ const { StateConcurrencyException, StateNotFoundException } = require('./exceptions')
10
6
 
11
7
  class Transition extends Operation {
8
+ /** a transition is the only operation that commits */
9
+ mutable = true
10
+
12
11
  #concurrency
13
12
 
14
13
  constructor (cascade, scope, contract, query, definition) {
@@ -26,33 +25,26 @@ class Transition extends Operation {
26
25
 
27
26
  store.scope = request.query ? await this.query(request.query) : this.scope.init()
28
27
 
29
- if (store.scope === null) {
28
+ if (store.scope === null || (store.scope.deleted === true && request.query?.options?.deleted !== true))
30
29
  throw new StateNotFoundException()
31
- }
32
-
30
+
33
31
  store.state = store.scope.get()
34
32
  }
35
33
 
36
34
  async commit (store) {
37
- const {
38
- scope,
39
- state,
40
- reply,
41
- retry
42
- } = store
35
+ const { scope, state, reply, retry } = store
43
36
 
44
37
  if (reply.error !== undefined) return
45
38
 
46
39
  scope.set(state)
47
40
 
48
- const result = await this.scope.commit(scope)
41
+ const result = await this.scope.commit(scope, store.request.input)
49
42
 
50
43
  if (result === false) {
51
- if (this.#concurrency === 'retry') {
44
+ if (this.#concurrency === 'retry')
52
45
  return retry()
53
- } else {
46
+ else
54
47
  throw new StateConcurrencyException()
55
- }
56
48
  }
57
49
  }
58
50
 
@@ -67,7 +59,7 @@ const RETRY = {
67
59
  base: 10,
68
60
  max: 5000,
69
61
  dispersion: 1,
70
- retries: Infinity
62
+ retries: 32
71
63
  }
72
64
 
73
65
  exports.Transition = Transition
@@ -18,13 +18,22 @@ class Transmission extends Connector {
18
18
  let i = 0
19
19
 
20
20
  while (reply === false && i < this.#bindings.length) {
21
- reply = await this.#bindings[i].request(request)
21
+ const binding = this.#bindings[i]
22
+
22
23
  i++
24
+
25
+ if (request?.task === true) {
26
+ if (binding.task === undefined)
27
+ continue
28
+
29
+ await binding.task(request)
30
+ reply = null
31
+ } else
32
+ reply = await binding.request(request)
23
33
  }
24
34
 
25
- if (reply === false) {
35
+ if (reply === false)
26
36
  throw new TransmissionException(`All (${this.#bindings.length}) bindings rejected.`)
27
- }
28
37
 
29
38
  return reply
30
39
  }
@@ -0,0 +1,11 @@
1
+ 'use strict'
2
+
3
+ const { Operation } = require('./operation')
4
+
5
+ class Unmanaged extends Operation {
6
+ acquire (context) {
7
+ context.state = this.scope.storage.raw
8
+ }
9
+ }
10
+
11
+ exports.Unmanaged = Unmanaged
@@ -3,6 +3,7 @@
3
3
  const { Component } = require('../src/component')
4
4
  const { codes } = require('../src/exceptions')
5
5
  const fixtures = require('./component.fixtures')
6
+ const { AssertionError } = require('node:assert')
6
7
 
7
8
  describe('Invocations', () => {
8
9
  const name = ['foo', 'bar'][Math.floor(2 * Math.random())]
@@ -16,12 +17,12 @@ describe('Invocations', () => {
16
17
  it('should invoke', async () => {
17
18
  await component.invoke(name)
18
19
 
19
- expect(invocation.invoke).toBeCalled()
20
+ expect(invocation.invoke).toHaveBeenCalled()
20
21
  })
21
22
 
22
23
  it('should throw on unknown invocation name', async () => {
23
24
  await expect(() => component.invoke('baz'))
24
- .rejects.toMatchObject({ code: codes.NotImplemented })
25
+ .rejects.toThrow(AssertionError)
25
26
  })
26
27
 
27
28
  it('should invoke input and query', async () => {
@@ -29,7 +30,7 @@ describe('Invocations', () => {
29
30
  const query = { test: Math.random() }
30
31
  await component.invoke(name, { input, query })
31
32
 
32
- expect(invocation.invoke).toBeCalledWith({ input, query })
33
+ expect(invocation.invoke).toHaveBeenCalledWith({ input, query })
33
34
  })
34
35
 
35
36
  it('should return io', async () => {
@@ -36,5 +36,13 @@ class FailingConnector extends Connector {
36
36
  }
37
37
  }
38
38
 
39
+ /** Stands for a connector waiting for something that never arrives. */
40
+ class StuckConnector extends Connector {
41
+ async open () {
42
+ return new Promise(() => undefined)
43
+ }
44
+ }
45
+
39
46
  exports.TestConnector = TestConnector
40
47
  exports.FailingConnector = FailingConnector
48
+ exports.StuckConnector = StuckConnector
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { timeout } = require('@toa.io/generic')
4
+
3
5
  const fixtures = require('./connector.fixtures')
4
6
 
5
7
  let sequence
@@ -105,6 +107,7 @@ describe('dependencies', () => {
105
107
  a.depends(b).depends(c)
106
108
  b.depends(d)
107
109
 
110
+ await a.connect()
108
111
  await a.disconnect()
109
112
 
110
113
  expect(sequence.indexOf('-a')).toBeLessThan(sequence.indexOf('-b'))
@@ -146,6 +149,23 @@ describe('dependencies', () => {
146
149
  expect(() => a.depends({})).toThrow()
147
150
  })
148
151
 
152
+ it('should disconnect while still connecting', async () => {
153
+ const stuck = new fixtures.StuckConnector()
154
+
155
+ a.depends(stuck).depends(b)
156
+
157
+ void a.connect()
158
+
159
+ while (b.connected !== true) await timeout(1)
160
+
161
+ // `a` never connects, and disconnecting must not wait for it to
162
+ expect(a.connected).toStrictEqual(false)
163
+
164
+ await a.disconnect()
165
+
166
+ expect(sequence).toEqual(['+b', '-b', '*b', '*a'])
167
+ })
168
+
149
169
  describe('errors', () => {
150
170
  let f
151
171
 
@@ -2,19 +2,19 @@
2
2
 
3
3
  const { generate } = require('randomstring')
4
4
 
5
- const { Conditions } = require('../../src/contract/conditions')
5
+ const { Contract } = require('../../src/contract/contract')
6
6
  const fixtures = require('./contract.fixtures')
7
7
 
8
- let conditions
8
+ let contract
9
9
 
10
10
  beforeEach(() => {
11
- conditions = new Conditions(fixtures.schema)
11
+ contract = new Contract(fixtures.schema)
12
12
  })
13
13
 
14
14
  it('should fit value', () => {
15
15
  const value = { foo: generate() }
16
16
 
17
- conditions.fit(value)
17
+ contract.fit(value)
18
18
 
19
19
  expect(fixtures.schema.fit).toHaveBeenCalledWith(value)
20
20
  })
@@ -22,5 +22,5 @@ it('should fit value', () => {
22
22
  it('should throw on invalid value', () => {
23
23
  const value = { invalid: true }
24
24
 
25
- expect(() => conditions.fit(value)).toThrow()
25
+ expect(() => contract.fit(value)).toThrow()
26
26
  })
@@ -3,10 +3,10 @@
3
3
  const clone = require('clone-deep')
4
4
  const { generate } = require('randomstring')
5
5
 
6
- jest.mock('../../src/contract/conditions')
6
+ jest.mock('../../src/contract/contract')
7
7
 
8
8
  const { Request } = require('../../src/contract/request')
9
- const { Conditions } = require('../../src/contract/conditions')
9
+ const { Contract } = require('../../src/contract/contract')
10
10
  const fixtures = require('./contract.fixtures')
11
11
 
12
12
  let contract
@@ -14,14 +14,14 @@ let contract
14
14
  beforeEach(() => {
15
15
  jest.clearAllMocks()
16
16
 
17
- contract = new Request(fixtures.schema)
17
+ contract = new Request(fixtures.schema, {})
18
18
  })
19
19
 
20
20
  const dummy = { schema: { properties: {} } }
21
21
 
22
22
  it('should extend Conditions', () => {
23
- expect(contract).toBeInstanceOf(Conditions)
24
- expect(Conditions).toHaveBeenCalledWith(fixtures.schema)
23
+ expect(contract).toBeInstanceOf(Contract)
24
+ expect(Contract).toHaveBeenCalledWith(fixtures.schema)
25
25
  })
26
26
 
27
27
  it('should fit request', () => {
@@ -29,7 +29,7 @@ it('should fit request', () => {
29
29
 
30
30
  contract.fit(request)
31
31
 
32
- expect(Conditions.mock.instances[0].fit).toHaveBeenCalledWith(request)
32
+ expect(Contract.mock.instances[0].fit).toHaveBeenCalledWith(request)
33
33
  })
34
34
 
35
35
  describe('schema', () => {
@@ -59,7 +59,7 @@ describe('schema', () => {
59
59
 
60
60
  it('should not contain query if declaration.query is false', () => {
61
61
  schema.properties.query = { type: 'null' }
62
- expect(Request.schema({ query: false }, dummy)).toStrictEqual(schema)
62
+ expect(Request.schema({ query: false }, dummy)).toMatchObject(schema)
63
63
  })
64
64
 
65
65
  it('should require query if declaration.query is true', () => {
@@ -110,3 +110,42 @@ describe('schema', () => {
110
110
  expect(objects.limit).toBeDefined()
111
111
  })
112
112
  })
113
+
114
+ describe('source', () => {
115
+ const schemas = require('@toa.io/schemas')
116
+
117
+ const compile = (definition, entity) =>
118
+ schemas.schema(Request.schema(definition, entity), { removeAdditional: true })
119
+
120
+ it('should declare source', () => {
121
+ const schema = Request.schema({}, dummy)
122
+
123
+ expect(schema.properties.source).toBeDefined()
124
+ expect(schema.properties.source.additionalProperties).toStrictEqual(false)
125
+ })
126
+
127
+ it('should pass known source variants', () => {
128
+ const schema = compile({}, undefined)
129
+
130
+ for (const source of [
131
+ { namespace: 'a', component: 'b', operation: 'c' },
132
+ { namespace: 'a', component: 'b', event: 'c' },
133
+ { service: 'exposition' }
134
+ ]) {
135
+ const request = { input: null, query: null, source }
136
+
137
+ expect(schema.fit(request)).toStrictEqual(null)
138
+ expect(request.source).toStrictEqual(source)
139
+ }
140
+ })
141
+
142
+ // `source` crosses the wire and keys the introspection map, so whatever
143
+ // a peer adds to it must not survive
144
+ it('should strip unknown source properties', () => {
145
+ const schema = compile({}, undefined)
146
+ const request = { input: null, query: null, source: { service: 'exposition', evil: 'x' } }
147
+
148
+ expect(schema.fit(request)).toStrictEqual(null)
149
+ expect(request.source).toStrictEqual({ service: 'exposition' })
150
+ })
151
+ })
@@ -0,0 +1,56 @@
1
+ 'use strict'
2
+
3
+ const { console } = require('openspan')
4
+ const { Connector } = require('../src/connector')
5
+ const { Discovery } = require('../src/discovery')
6
+
7
+ class Lookup extends Connector {
8
+ invoke = jest.fn(async () => ({ operations: {} }))
9
+ }
10
+
11
+ /** @type {Lookup} */
12
+ let lookup
13
+
14
+ /** @type {Discovery} */
15
+ let discovery
16
+
17
+ /** @type {jest.SpyInstance} */
18
+ let warn
19
+
20
+ const locator = { id: 'dummies.one' }
21
+
22
+ beforeEach(async () => {
23
+ jest.useFakeTimers()
24
+
25
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
26
+ lookup = new Lookup()
27
+ discovery = new Discovery(async () => lookup)
28
+
29
+ await discovery.connect()
30
+ })
31
+
32
+ afterEach(() => {
33
+ warn.mockRestore()
34
+ jest.useRealTimers()
35
+ })
36
+
37
+ it('should not warn if a lookup is answered', async () => {
38
+ await discovery.lookup(locator)
39
+
40
+ jest.advanceTimersByTime(60_000)
41
+
42
+ expect(warn).not.toHaveBeenCalled()
43
+ })
44
+
45
+ it('should keep warning while a lookup is unanswered', async () => {
46
+ lookup.invoke.mockImplementation(() => new Promise(() => undefined))
47
+
48
+ void discovery.lookup(locator)
49
+
50
+ await jest.advanceTimersByTimeAsync(12_000)
51
+
52
+ expect(warn).toHaveBeenCalledTimes(2)
53
+
54
+ expect(warn).toHaveBeenLastCalledWith('Waiting for lookup response',
55
+ { component: locator.id, waiting: 10 })
56
+ })