@toa.io/extensions.configuration 1.0.0-alpha.291 → 1.0.0-alpha.293

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 (88) hide show
  1. package/package.json +16 -6
  2. package/transpiled/Aspect.d.ts +1 -1
  3. package/transpiled/Aspect.js +1 -1
  4. package/transpiled/Aspect.js.map +1 -1
  5. package/transpiled/Client.js +1 -1
  6. package/transpiled/Client.js.map +1 -1
  7. package/transpiled/Composition.js +1 -1
  8. package/transpiled/Composition.js.map +1 -1
  9. package/transpiled/Factory.d.ts +1 -1
  10. package/transpiled/UI.js +1 -1
  11. package/transpiled/UI.js.map +1 -1
  12. package/transpiled/configuration.d.ts +1 -1
  13. package/transpiled/configuration.js +1 -1
  14. package/transpiled/configuration.js.map +1 -1
  15. package/transpiled/index.d.ts +0 -3
  16. package/transpiled/index.js +0 -4
  17. package/transpiled/index.js.map +1 -1
  18. package/ui/dist/_app/immutable/chunks/BVp7FTPj.js +1 -0
  19. package/ui/dist/_app/immutable/chunks/{CNZAv2Es.js → Ba25-8aA.js} +1 -1
  20. package/ui/dist/_app/immutable/chunks/{BV5cDXGB.js → D5h0LnT-.js} +1 -1
  21. package/ui/dist/_app/immutable/chunks/{vl8APhvf.js → DJFe2eVw.js} +2 -2
  22. package/ui/dist/_app/immutable/chunks/{B_CH3nFk.js → iBEDxyKc.js} +1 -1
  23. package/ui/dist/_app/immutable/entry/{app.CK-db-gz.js → app.BaUwlvvj.js} +2 -2
  24. package/ui/dist/_app/immutable/entry/start.BQIMr3E0.js +1 -0
  25. package/ui/dist/_app/immutable/nodes/{0.vPwVABRs.js → 0.Bdnf_m1Q.js} +1 -1
  26. package/ui/dist/_app/immutable/nodes/{1.DS63PME5.js → 1.ChFvQfy2.js} +1 -1
  27. package/ui/dist/_app/immutable/nodes/{2.KRpWYBfz.js → 2.DoZuKZSw.js} +1 -1
  28. package/ui/dist/_app/immutable/nodes/3.BalX8tPE.js +1 -0
  29. package/ui/dist/_app/immutable/nodes/{4.D8lluH3u.js → 4.w2QdhCXS.js} +1 -1
  30. package/ui/dist/_app/version.json +1 -1
  31. package/ui/dist/index.html +10 -10
  32. package/CHANGELOG.md +0 -35
  33. package/components/configuration.values/source/lib/map.test.ts +0 -42
  34. package/components/configuration.values/tsconfig.tsbuildinfo +0 -1
  35. package/cucumber.mjs +0 -8
  36. package/features/site/_app/immutable/asset.js +0 -1
  37. package/features/site/favicon.ico +0 -0
  38. package/features/site/index.html +0 -10
  39. package/features/steps/UI.ts +0 -98
  40. package/features/steps/tsconfig.json +0 -9
  41. package/features/ui.feature +0 -93
  42. package/notes/consistency.md +0 -70
  43. package/schemas/annotation.cos.yaml +0 -29
  44. package/schemas/manifest.cos.yaml +0 -9
  45. package/source/Aspect.test.ts +0 -119
  46. package/source/Aspect.ts +0 -91
  47. package/source/Client.test.ts +0 -125
  48. package/source/Client.ts +0 -251
  49. package/source/Composition.ts +0 -57
  50. package/source/Factory.ts +0 -34
  51. package/source/Secret.test.ts +0 -32
  52. package/source/Secret.ts +0 -32
  53. package/source/UI.ts +0 -213
  54. package/source/configuration.test.ts +0 -152
  55. package/source/configuration.ts +0 -74
  56. package/source/const.ts +0 -30
  57. package/source/context.ts +0 -13
  58. package/source/deployment.test.ts +0 -112
  59. package/source/deployment.ts +0 -160
  60. package/source/epoch.test.ts +0 -35
  61. package/source/epoch.ts +0 -22
  62. package/source/index.ts +0 -10
  63. package/source/manifest.test.ts +0 -24
  64. package/source/manifest.ts +0 -13
  65. package/source/schemas.ts +0 -9
  66. package/transpiled/const.d.ts +0 -18
  67. package/transpiled/const.js +0 -22
  68. package/transpiled/const.js.map +0 -1
  69. package/transpiled/context.d.ts +0 -4
  70. package/transpiled/context.js +0 -9
  71. package/transpiled/context.js.map +0 -1
  72. package/transpiled/deployment.d.ts +0 -17
  73. package/transpiled/deployment.js +0 -107
  74. package/transpiled/deployment.js.map +0 -1
  75. package/transpiled/epoch.d.ts +0 -2
  76. package/transpiled/epoch.js +0 -19
  77. package/transpiled/epoch.js.map +0 -1
  78. package/transpiled/manifest.d.ts +0 -6
  79. package/transpiled/manifest.js +0 -6
  80. package/transpiled/manifest.js.map +0 -1
  81. package/transpiled/schemas.d.ts +0 -3
  82. package/transpiled/schemas.js +0 -7
  83. package/transpiled/schemas.js.map +0 -1
  84. package/tsconfig.json +0 -13
  85. package/tsconfig.tsbuildinfo +0 -1
  86. package/ui/dist/_app/immutable/chunks/3Kbn_1Vv.js +0 -1
  87. package/ui/dist/_app/immutable/entry/start.Bs7xr9Ne.js +0 -1
  88. package/ui/dist/_app/immutable/nodes/3.DCxORePS.js +0 -1
@@ -1,93 +0,0 @@
1
- @ui
2
- Feature: Publishing the UI
3
-
4
- The values service serves the UI on its own port, out of the directory `ui` builds. The
5
- mount path is baked into the bundle and forwarded by the ingress, so the server routes
6
- relative to it.
7
-
8
- Scenario: The root is the way in
9
- Given the UI is published
10
- When "/" is requested
11
- Then the status is 302
12
- And the "location" header is "/.configuration/"
13
-
14
- Scenario: The UI answers at its mount path
15
- Given the UI is published
16
- When "/.configuration" is requested
17
- Then the status is 200
18
- And the body contains "the page"
19
-
20
- Scenario: A trailing slash is the same resource
21
- Given the UI is published
22
- When "/.configuration/" is requested
23
- Then the status is 200
24
- And the body contains "the page"
25
-
26
- Scenario: A query string does not change the resource
27
- Given the UI is published
28
- When "/.configuration?epoch=e1" is requested
29
- Then the status is 200
30
- And the body contains "the page"
31
-
32
- Scenario: An unknown route is the page
33
- The client router knows what routes there are; this server does not.
34
-
35
- Given the UI is published
36
- When "/.configuration/whatever" is requested
37
- Then the status is 200
38
- And the body contains "the page"
39
-
40
- Scenario: A route that looks like a file is still a route
41
- A component is named `namespace.component`, and the address of its configuration
42
- carries that name — so an extension is no way to tell an asset from a route.
43
-
44
- Given the UI is published
45
- When "/.configuration/dummies.dummy/" is requested
46
- Then the status is 200
47
- And the body contains "the page"
48
-
49
- Scenario: And with no trailing slash to give it away
50
- Given the UI is published
51
- When "/.configuration/dummies.dummy" is requested
52
- Then the status is 200
53
- And the body contains "the page"
54
-
55
- Scenario: A hashed asset is served forever
56
- Given the UI is published
57
- When "/.configuration/_app/immutable/asset.js" is requested
58
- Then the status is 200
59
- And the "content-type" header is "text/javascript; charset=utf-8"
60
- And the "cache-control" header is "public, max-age=31536000, immutable"
61
-
62
- Scenario: An icon is held, but not forever
63
- It is named for what it is rather than for its content, so it is asked about again.
64
-
65
- Given the UI is published
66
- When "/.configuration/favicon.ico" is requested
67
- Then the status is 200
68
- And the "cache-control" header is "public, max-age=86400"
69
-
70
- Scenario: The page itself is never cached
71
- Given the UI is published
72
- When "/.configuration/" is requested
73
- Then the "cache-control" header is "no-cache"
74
-
75
- Scenario: A missing asset is missing
76
- Given the UI is published
77
- When "/.configuration/_app/immutable/absent.js" is requested
78
- Then the status is 404
79
-
80
- Scenario: The path of the values API is not served by the UI
81
- Given the UI is published
82
- When "/configuration/values/" is requested
83
- Then the status is 404
84
-
85
- Scenario: A path that only looks like the mount path is absent
86
- Given the UI is published
87
- When "/.configurationable" is requested
88
- Then the status is 404
89
-
90
- Scenario: Nothing outside the site is reachable
91
- Given the UI is published
92
- When "/.configuration/%2e%2e/%2e%2e/package.json" is requested
93
- Then the status is 404
@@ -1,70 +0,0 @@
1
- # Configuration Consistency
2
-
3
- ![Not Implemented](https://img.shields.io/badge/Not_Implemented-red)
4
-
5
- ## Problem Definition
6
-
7
- At arbitrary moments when the configuration has changed, some distributed operations have started
8
- but haven't yet
9
- finished. This leads to operations that start with certain configuration values but will finish with
10
- another, which may
11
- result in logical problems.
12
-
13
- Given that distributed operations may have arbitrary participants and last for an arbitrary period,
14
- there is no such
15
- single moment when configuration can be safely changed. Thus, it must be a process.
16
-
17
- ## ~~Not a~~ Solution
18
-
19
- 1. Configuration is a single versioned object.
20
- 2. Configuration version value is included in the Uniform Communication Interface.
21
- 3. The first distributed operation participant (which is the one who has received a UCI without the
22
- configuration
23
- version value) must include in the outgoing UCI the configuration version value that it considers
24
- as current.
25
- 4. Non-first distributed operations participants (which is those who has received a UCI with the
26
- configuration version
27
- value) must use the configuration version specified in the UCI.
28
- 5. Non-first distributed operation participants must include in the outgoing UCI the same
29
- configuration version value as
30
- they received in the incoming.
31
-
32
- ## Restrictions
33
-
34
- 1. Configuration updates are always backward compatible.
35
- 2. Configuration updates are always being delivered before algorithm updates.
36
- 1. Including federated deployment, that is: first deliver configuration to all facilities (data
37
- centers, zones,
38
- whatever), then deliver algorithm updates.
39
- 3. Configuration storage and access solution must provide the transactional updates, that is if any
40
- arbitrary
41
- participant observed a certain configuration version, then any other participant is guaranteed to
42
- be able to
43
- subsequently observe that version.
44
- 1. Including federated deployment[^1].
45
-
46
- ## Solution
47
-
48
- The configuration compatibility problem described above is a particular case of a common
49
- compatibility problem for
50
- distributed operation participants. It is not the only configuration that may change while a
51
- distributed operation is
52
- running, but participant algorithms themselves. This leads to the conclusion that this particular
53
- solution must be
54
- propagated to algorithm versions, that is: which system (as a set of algorithms and configuration)
55
- version was used to
56
- start the operation, and that system version must be used to finish the operation.
57
-
58
- This kind of solution results in the need to run all versions of the system with appropriate message
59
- routing. The
60
- implementation complexity of this solution and its operations costs are considered unreasonable.
61
- However, an attempt
62
- will be made to implement this solution with a certain
63
- constraints [#147](https://github.com/toa-io/toa/issues/147).
64
-
65
- [^1]:
66
- Since it looks like there is no reasonable way to provide this kind of guarantee without
67
- significant performance
68
- and/or availability impact, it may be implemented as a mechanism with read retries and with a
69
- timeout considered as
70
- “enough for the most of fail-over scenarios”.
@@ -1,29 +0,0 @@
1
- # context.toa.yaml configuration annotation
2
- #
3
- # Every key names a component and holds that component's values, except `resources`, which
4
- # is the values service's own: it deploys like any other and has to state what it may take.
5
- # A component actually named `resources` is written with its namespace, `default.resources`
6
- # — the bare form is only shorthand for that.
7
- properties:
8
- resources:
9
- # `null` deploys the service without any, which every deployment has to state one way
10
- # or the other — see `migrations/262.md`.
11
- type: object
12
- nullable: true
13
- properties:
14
- cpu:
15
- type: array
16
- items:
17
- type: string
18
- minItems: 2
19
- maxItems: 2
20
- memory:
21
- type: array
22
- items:
23
- type: string
24
- minItems: 2
25
- maxItems: 2
26
- patternProperties:
27
- ^(?!resources$).+$:
28
- type: object
29
- type: object
@@ -1,9 +0,0 @@
1
- properties:
2
- schema:
3
- type: object
4
- defaults:
5
- type: object
6
- additionalProperties: false
7
- type: object
8
- required:
9
- - schema
@@ -1,119 +0,0 @@
1
- import { it, beforeEach, afterEach, mock } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { isDeepStrictEqual } from 'node:util'
4
-
5
- import { Connector, Locator } from '@toa.io/core'
6
- import { generate } from 'randomstring'
7
- import { Aspect } from './Aspect.js'
8
- import type { Client, Listener } from './Client.js'
9
- import type { Manifest } from './manifest.js'
10
-
11
- class Fake extends Connector {
12
- public readonly fetch = mock.fn(async () => ({
13
- configuration: { foo: 'served' },
14
- created: 5
15
- }))
16
- public readonly subscribe = mock.fn()
17
- public readonly unsubscribe = mock.fn()
18
- }
19
-
20
- const manifest: Manifest = {
21
- schema: {
22
- type: 'object',
23
- properties: {
24
- foo: { type: 'string' },
25
- bar: { type: 'object', properties: { baz: { type: 'string' } } }
26
- }
27
- },
28
- defaults: { bar: { baz: 'quux' } }
29
- }
30
-
31
- let locator: Locator
32
-
33
- beforeEach(() => {
34
- locator = new Locator(generate(), generate())
35
- })
36
-
37
- afterEach(() => {
38
- delete process.env['TOA_CONFIGURATION_' + locator.uppercase]
39
- })
40
-
41
- it('should be named', async () => {
42
- assert.deepStrictEqual(new Aspect(locator, manifest, null).name, 'configuration')
43
- })
44
-
45
- it('should resolve locally without a client', async () => {
46
- process.env['TOA_CONFIGURATION_' + locator.uppercase] = JSON.stringify({ foo: 'local' })
47
-
48
- const aspect = new Aspect(locator, manifest, null)
49
-
50
- await aspect.connect()
51
-
52
- assert.deepStrictEqual(aspect.invoke(), { foo: 'local', bar: { baz: 'quux' } })
53
- assert.deepStrictEqual(aspect.invoke(['foo']), 'local')
54
- assert.deepStrictEqual(aspect.invoke(['bar', 'baz']), 'quux')
55
- })
56
-
57
- it('should fetch from the client and follow it', async () => {
58
- const client = new Fake()
59
- const aspect = new Aspect(locator, manifest, client as unknown as Client)
60
-
61
- await aspect.connect()
62
-
63
- assert.deepStrictEqual(client.connected, true)
64
- assert.strictEqual(client.fetch.mock.callCount(), 1)
65
-
66
- const [component, epoch] = client.fetch.mock.calls[0].arguments as unknown as [
67
- string,
68
- string
69
- ]
70
-
71
- assert.deepStrictEqual(component, locator.id)
72
- assert.match(epoch, /^[a-f0-9]{64}$/)
73
- // what the service serves is what was stored, and the manifest defaults are not applied to it
74
- assert.deepStrictEqual(aspect.invoke(), { foo: 'served' })
75
-
76
- assert.ok(
77
- client.subscribe.mock.calls.some(
78
- (call: any) =>
79
- call.arguments.length === 3 &&
80
- isDeepStrictEqual(call.arguments[0], component) &&
81
- isDeepStrictEqual(call.arguments[1], epoch) &&
82
- typeof call.arguments[2] === 'function'
83
- )
84
- )
85
-
86
- const listener = client.subscribe.mock.calls[0].arguments[2] as Listener
87
-
88
- listener({ configuration: { foo: 'updated' }, created: 6 })
89
-
90
- assert.deepStrictEqual(aspect.invoke(['foo']), 'updated')
91
-
92
- // what is not newer than the held value is left alone
93
- listener({ configuration: { foo: 'stale' }, created: 6 })
94
- listener({ configuration: { foo: 'older' }, created: 4 })
95
-
96
- assert.deepStrictEqual(aspect.invoke(['foo']), 'updated')
97
-
98
- // a value that does not fit keeps the previous one
99
- listener({ configuration: { foo: { nested: true } }, created: 7 })
100
-
101
- assert.deepStrictEqual(aspect.invoke(['foo']), 'updated')
102
-
103
- // and the one after it still applies
104
- listener({ configuration: { foo: 'latest' }, created: 8 })
105
-
106
- assert.deepStrictEqual(aspect.invoke(['foo']), 'latest')
107
-
108
- await aspect.disconnect()
109
-
110
- assert.ok(
111
- client.unsubscribe.mock.calls.some(
112
- (call: any) =>
113
- call.arguments.length === 3 &&
114
- isDeepStrictEqual(call.arguments[0], component) &&
115
- isDeepStrictEqual(call.arguments[1], epoch) &&
116
- isDeepStrictEqual(call.arguments[2], listener)
117
- )
118
- )
119
- })
package/source/Aspect.ts DELETED
@@ -1,91 +0,0 @@
1
- import { console } from 'openspan'
2
- import { Connector, type Locator } from '@toa.io/core'
3
- import type { extensions } from '@toa.io/core/types'
4
- import { fit, local, type Node } from './configuration.js'
5
- import { epoch } from './epoch.js'
6
- import type { Client, Value } from './Client.js'
7
- import type { Manifest } from './manifest.js'
8
-
9
- export class Aspect extends Connector implements extensions.Aspect {
10
- public readonly name = 'configuration'
11
-
12
- private readonly locator: Locator
13
- private readonly manifest: Manifest
14
- private readonly client: Client | null
15
- private readonly epoch: string
16
- private value: Node = {}
17
- private created = 0
18
-
19
- /**
20
- * Without a client the value is local: the variable, the defaults and the schema.
21
- * With one, the value is what the service holds, and it follows the service.
22
- */
23
- public constructor(locator: Locator, manifest: Manifest, client: Client | null) {
24
- super()
25
-
26
- this.locator = locator
27
- this.manifest = manifest
28
- this.client = client
29
- this.epoch = epoch(manifest.schema)
30
-
31
- if (client !== null) this.depends(client)
32
- }
33
-
34
- public invoke(path?: string[]): any {
35
- let cursor: any = this.value
36
-
37
- if (path !== undefined) for (const segment of path) cursor = cursor[segment]
38
-
39
- return cursor
40
- }
41
-
42
- protected override async open(): Promise<void> {
43
- if (this.client === null) {
44
- this.value = local(this.locator, this.manifest)
45
-
46
- return
47
- }
48
-
49
- const { configuration, created } = await this.client.fetch(
50
- this.locator.id,
51
- this.epoch
52
- )
53
-
54
- this.value = fit(configuration, this.manifest)
55
- this.created = created
56
- this.client.subscribe(this.locator.id, this.epoch, this.listener)
57
-
58
- console.info('Configuration resolved', {
59
- component: this.locator.id,
60
- epoch: this.epoch,
61
- created
62
- })
63
- }
64
-
65
- protected override async close(): Promise<void> {
66
- this.client?.unsubscribe(this.locator.id, this.epoch, this.listener)
67
- }
68
-
69
- private readonly listener = ({ configuration, created }: Value): void => {
70
- // deliveries may repeat or cross: only what is newer than the held value replaces it
71
- if (created <= this.created) return
72
-
73
- try {
74
- this.value = fit(configuration, this.manifest)
75
- this.created = created
76
-
77
- console.info('Configuration updated', {
78
- component: this.locator.id,
79
- epoch: this.epoch,
80
- created
81
- })
82
- } catch (error) {
83
- // the service validated it against the schema of its epoch, so the two schemas differ
84
- console.error('Configuration value does not match the schema', {
85
- component: this.locator.id,
86
- epoch: this.epoch,
87
- error
88
- })
89
- }
90
- }
91
- }
@@ -1,125 +0,0 @@
1
- import { it, beforeEach, afterEach, mock } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { isDeepStrictEqual } from 'node:util'
4
-
5
- import { Connector, type Locator, type Receiver } from '@toa.io/core'
6
- import type { Message } from '@toa.io/core/types'
7
- import { timeout } from '@toa.io/generic'
8
- import { Client, type Fetched } from './Client.js'
9
- import { EVENT } from './const.js'
10
- import type { Host } from './Factory.js'
11
-
12
- class Remote extends Connector {
13
- public readonly invoke = mock.fn(
14
- async (
15
- _endpoint: string,
16
- request: { input: Array<{ component: string; epoch: string }> }
17
- ) =>
18
- request.input.map((pair): Fetched => ({
19
- ...pair,
20
- configuration: this.values[pair.component] ?? null,
21
- created: this.values[pair.component] === undefined ? 0 : 7
22
- }))
23
- )
24
-
25
- public values: Record<string, object | null> = {}
26
- }
27
-
28
- let remote: Remote
29
- let receiver: Receiver | null
30
- let host: Host
31
- let client: Client
32
-
33
- beforeEach(() => {
34
- remote = new Remote()
35
- receiver = null
36
-
37
- host = {
38
- remote: mock.fn(async (locator: Locator) => {
39
- assert.deepStrictEqual(locator.id, 'configuration.values')
40
-
41
- return remote
42
- }),
43
- receive: mock.fn(async (label: string, consumer: Receiver) => {
44
- assert.deepStrictEqual(label, EVENT)
45
-
46
- receiver = consumer
47
-
48
- return new Connector()
49
- })
50
- } as unknown as Host
51
-
52
- client = new Client(host, { base: 10, max: 20, warn: 2 })
53
- })
54
-
55
- afterEach(async () => {
56
- await client.disconnect()
57
- })
58
-
59
- it('should send the requests of one tick as one call', async () => {
60
- remote.values = { 'a.one': { foo: 1 }, 'a.two': { foo: 2 } }
61
-
62
- await client.connect()
63
-
64
- const [one, two] = await Promise.all([
65
- client.fetch('a.one', 'e1'),
66
- client.fetch('a.two', 'e2')
67
- ])
68
-
69
- assert.deepStrictEqual(one, { configuration: { foo: 1 }, created: 7 })
70
- assert.deepStrictEqual(two, { configuration: { foo: 2 }, created: 7 })
71
- assert.strictEqual(remote.invoke.mock.callCount(), 1)
72
- assert.deepStrictEqual(remote.invoke.mock.calls[0].arguments[1], {
73
- input: [
74
- { component: 'a.one', epoch: 'e1' },
75
- { component: 'a.two', epoch: 'e2' }
76
- ]
77
- })
78
- })
79
-
80
- it('should keep asking until served', async () => {
81
- await client.connect()
82
-
83
- const fetching = client.fetch('a.one', 'e1')
84
- const deadline = Date.now() + 1000
85
-
86
- while (remote.invoke.mock.calls.length < 2 && Date.now() < deadline) await timeout(5)
87
-
88
- assert.ok(remote.invoke.mock.calls.length >= 2)
89
-
90
- remote.values = { 'a.one': { foo: 1 } }
91
-
92
- assert.deepStrictEqual((await fetching).configuration, { foo: 1 })
93
- })
94
-
95
- it('should hand a created object to its subscribers', async () => {
96
- await client.connect()
97
-
98
- const listener = mock.fn()
99
- const other = mock.fn()
100
-
101
- client.subscribe('a.one', 'e1', listener)
102
- client.subscribe('a.one', 'e0', other)
103
-
104
- await receiver!.receive({
105
- payload: { component: 'a.one', epoch: 'e1', configuration: { foo: 2 }, CREATED: 12 }
106
- } satisfies Message)
107
-
108
- assert.ok(
109
- listener.mock.calls.some(
110
- (call: any) =>
111
- call.arguments.length === 1 &&
112
- isDeepStrictEqual(call.arguments[0], { configuration: { foo: 2 }, created: 12 })
113
- )
114
- )
115
- assert.strictEqual(other.mock.callCount(), 0)
116
- assert.strictEqual(remote.invoke.mock.callCount(), 0)
117
-
118
- client.unsubscribe('a.one', 'e1', listener)
119
-
120
- await receiver!.receive({
121
- payload: { component: 'a.one', epoch: 'e1', configuration: { foo: 3 }, CREATED: 13 }
122
- } satisfies Message)
123
-
124
- assert.strictEqual(listener.mock.callCount(), 1)
125
- })