sounding 0.0.3 → 0.1.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/README.md +336 -1
- package/bin/sounding.js +156 -0
- package/index.js +23 -0
- package/lib/create-app-manager.js +380 -26
- package/lib/create-auth-helpers.js +168 -21
- package/lib/create-browser-manager.js +578 -31
- package/lib/create-error.js +35 -0
- package/lib/create-expect.js +1070 -27
- package/lib/create-helper-runner.js +38 -2
- package/lib/create-mail-capture.js +174 -25
- package/lib/create-mailbox.js +20 -0
- package/lib/create-request-client.js +635 -57
- package/lib/create-runtime.js +222 -21
- package/lib/create-socket-manager.js +706 -0
- package/lib/create-test-api.js +491 -102
- package/lib/create-visit-client.js +40 -2
- package/lib/create-world-engine.js +106 -7
- package/lib/create-world-loader.js +150 -8
- package/lib/default-config.js +26 -0
- package/lib/define-world.js +27 -2
- package/lib/init-project.js +403 -0
- package/lib/merge-config.js +11 -0
- package/lib/normalize-config.js +16 -19
- package/lib/resolve-auth-config.js +36 -0
- package/lib/resolve-datastore.js +50 -7
- package/lib/resolve-dependency.js +145 -0
- package/lib/test-runner.js +427 -0
- package/lib/trial-context.js +29 -0
- package/lib/types.js +675 -0
- package/lib/validate-config.js +633 -0
- package/lib/validate-test-args.js +480 -0
- package/package.json +16 -2
|
@@ -1,15 +1,41 @@
|
|
|
1
|
+
const { createSoundingError } = require('./create-error')
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('./types').AnyRecord} AnyRecord */
|
|
4
|
+
/** @typedef {import('./types').SoundingHelperRunner} SoundingHelperRunner */
|
|
5
|
+
/** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {SoundingSailsApp} sails
|
|
9
|
+
* @param {string} identity
|
|
10
|
+
* @returns {any}
|
|
11
|
+
*/
|
|
1
12
|
function resolveHelper(sails, identity) {
|
|
2
13
|
return identity
|
|
3
14
|
.split('.')
|
|
4
15
|
.reduce((current, segment) => current?.[segment], sails.helpers)
|
|
5
16
|
}
|
|
6
17
|
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ sails: SoundingSailsApp }} input
|
|
20
|
+
* @returns {SoundingHelperRunner}
|
|
21
|
+
*/
|
|
7
22
|
function createHelperRunner({ sails }) {
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} identity
|
|
25
|
+
* @param {AnyRecord} [inputs]
|
|
26
|
+
* @returns {Promise<any>}
|
|
27
|
+
*/
|
|
8
28
|
async function invoke(identity, inputs = {}) {
|
|
9
29
|
const helper = resolveHelper(sails, identity)
|
|
10
30
|
|
|
11
31
|
if (!helper) {
|
|
12
|
-
throw
|
|
32
|
+
throw createSoundingError({
|
|
33
|
+
code: 'E_SOUNDING_HELPER_UNKNOWN',
|
|
34
|
+
message: `Unknown Sounding helper: ${identity}`,
|
|
35
|
+
details: {
|
|
36
|
+
identity,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
13
39
|
}
|
|
14
40
|
|
|
15
41
|
if (typeof helper.with === 'function') {
|
|
@@ -20,9 +46,19 @@ function createHelperRunner({ sails }) {
|
|
|
20
46
|
return helper(inputs)
|
|
21
47
|
}
|
|
22
48
|
|
|
23
|
-
throw
|
|
49
|
+
throw createSoundingError({
|
|
50
|
+
code: 'E_SOUNDING_HELPER_NOT_CALLABLE',
|
|
51
|
+
message: `Sounding helper \`${identity}\` is not callable.`,
|
|
52
|
+
details: {
|
|
53
|
+
identity,
|
|
54
|
+
},
|
|
55
|
+
})
|
|
24
56
|
}
|
|
25
57
|
|
|
58
|
+
/**
|
|
59
|
+
* @param {string[]} [path]
|
|
60
|
+
* @returns {SoundingHelperRunner}
|
|
61
|
+
*/
|
|
26
62
|
function buildProxy(path = []) {
|
|
27
63
|
const callable = async (...args) => {
|
|
28
64
|
if (path.length === 0) {
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
const path = require('node:path')
|
|
2
2
|
const url = require('node:url')
|
|
3
|
-
|
|
3
|
+
const { createSoundingError } = require('./create-error')
|
|
4
|
+
const { getTrialContext } = require('./trial-context')
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAIL_LAYOUT = 'mail'
|
|
7
|
+
const mailCaptureRegistry = new WeakMap()
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('./types').AnyRecord} AnyRecord */
|
|
10
|
+
/** @typedef {import('./types').SoundingMailCapture} SoundingMailCapture */
|
|
11
|
+
/** @typedef {import('./types').SoundingMailbox} SoundingMailbox */
|
|
12
|
+
/** @typedef {import('./types').SoundingMailMessage} SoundingMailMessage */
|
|
13
|
+
/** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {any} value
|
|
17
|
+
* @returns {any[]}
|
|
18
|
+
*/
|
|
4
19
|
function normalizeList(value) {
|
|
5
20
|
if (value === undefined || value === null || value === '') {
|
|
6
21
|
return []
|
|
@@ -20,6 +35,10 @@ function normalizeList(value) {
|
|
|
20
35
|
return [value]
|
|
21
36
|
}
|
|
22
37
|
|
|
38
|
+
/**
|
|
39
|
+
* @param {string | undefined} html
|
|
40
|
+
* @returns {string[]}
|
|
41
|
+
*/
|
|
23
42
|
function extractLinks(html) {
|
|
24
43
|
if (!html) {
|
|
25
44
|
return []
|
|
@@ -29,6 +48,11 @@ function extractLinks(html) {
|
|
|
29
48
|
return [...matches].map((match) => match[1])
|
|
30
49
|
}
|
|
31
50
|
|
|
51
|
+
/**
|
|
52
|
+
* @param {AnyRecord} [inputs]
|
|
53
|
+
* @param {string[]} [links]
|
|
54
|
+
* @returns {string | undefined}
|
|
55
|
+
*/
|
|
32
56
|
function resolvePrimaryLink(inputs = {}, links = []) {
|
|
33
57
|
const templateData = inputs.templateData || {}
|
|
34
58
|
const preferredKeys = [
|
|
@@ -50,6 +74,10 @@ function resolvePrimaryLink(inputs = {}, links = []) {
|
|
|
50
74
|
return links.find((link) => !/\/unsubscribe\b/i.test(link)) || links[0]
|
|
51
75
|
}
|
|
52
76
|
|
|
77
|
+
/**
|
|
78
|
+
* @param {any} view
|
|
79
|
+
* @returns {Promise<string | undefined>}
|
|
80
|
+
*/
|
|
53
81
|
function createRenderViewPromise(view) {
|
|
54
82
|
if (!view) {
|
|
55
83
|
return Promise.resolve(undefined)
|
|
@@ -62,22 +90,28 @@ function createRenderViewPromise(view) {
|
|
|
62
90
|
return Promise.resolve(view)
|
|
63
91
|
}
|
|
64
92
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
/**
|
|
94
|
+
* @param {SoundingSailsApp} sails
|
|
95
|
+
* @param {AnyRecord} [inputs]
|
|
96
|
+
* @param {AnyRecord} [options]
|
|
97
|
+
* @returns {Promise<string | undefined>}
|
|
98
|
+
*/
|
|
99
|
+
async function renderTemplatePreview(sails, inputs = {}, options = {}) {
|
|
100
|
+
const { template, templateData = {} } = inputs
|
|
101
|
+
|
|
70
102
|
if (!template || typeof sails?.renderView !== 'function') {
|
|
71
103
|
return undefined
|
|
72
104
|
}
|
|
73
105
|
|
|
106
|
+
const resolvedLayout = resolvePreviewLayout(sails, inputs, options)
|
|
74
107
|
const emailTemplatePath = path.join('emails/', template)
|
|
108
|
+
/** @type {string | false} */
|
|
75
109
|
let emailTemplateLayout = false
|
|
76
110
|
|
|
77
|
-
if (
|
|
111
|
+
if (resolvedLayout) {
|
|
78
112
|
emailTemplateLayout = path.relative(
|
|
79
113
|
path.dirname(emailTemplatePath),
|
|
80
|
-
path.resolve('layouts/',
|
|
114
|
+
path.resolve('layouts/', resolvedLayout)
|
|
81
115
|
)
|
|
82
116
|
}
|
|
83
117
|
|
|
@@ -90,10 +124,50 @@ async function renderTemplatePreview(sails, {
|
|
|
90
124
|
)
|
|
91
125
|
}
|
|
92
126
|
|
|
127
|
+
/**
|
|
128
|
+
* @param {SoundingSailsApp} sails
|
|
129
|
+
* @returns {AnyRecord}
|
|
130
|
+
*/
|
|
93
131
|
function resolveMailConfig(sails) {
|
|
94
132
|
return sails?.config?.mail || {}
|
|
95
133
|
}
|
|
96
134
|
|
|
135
|
+
/**
|
|
136
|
+
* @param {SoundingSailsApp} sails
|
|
137
|
+
* @param {AnyRecord} [options]
|
|
138
|
+
* @returns {AnyRecord}
|
|
139
|
+
*/
|
|
140
|
+
function resolveSoundingMailConfig(sails, options = {}) {
|
|
141
|
+
if (options.mailSettings) {
|
|
142
|
+
return options.mailSettings
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const soundingConfig =
|
|
146
|
+
options.soundingConfig || options.config || sails?.config?.sounding || {}
|
|
147
|
+
|
|
148
|
+
return soundingConfig.mail || {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {SoundingSailsApp} sails
|
|
153
|
+
* @param {AnyRecord} [inputs]
|
|
154
|
+
* @param {AnyRecord} [options]
|
|
155
|
+
* @returns {string | false | undefined}
|
|
156
|
+
*/
|
|
157
|
+
function resolvePreviewLayout(sails, inputs = {}, options = {}) {
|
|
158
|
+
if (Object.prototype.hasOwnProperty.call(inputs, 'layout')) {
|
|
159
|
+
return inputs.layout
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const mailSettings = resolveSoundingMailConfig(sails, options)
|
|
163
|
+
|
|
164
|
+
if (Object.prototype.hasOwnProperty.call(mailSettings, 'layout')) {
|
|
165
|
+
return mailSettings.layout
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return DEFAULT_MAIL_LAYOUT
|
|
169
|
+
}
|
|
170
|
+
|
|
97
171
|
function resolveMailerName(sails, inputs = {}) {
|
|
98
172
|
return inputs.mailer || process.env.MAIL_MAILER || resolveMailConfig(sails).default
|
|
99
173
|
}
|
|
@@ -125,10 +199,17 @@ function toErrorShape(error) {
|
|
|
125
199
|
}
|
|
126
200
|
}
|
|
127
201
|
|
|
128
|
-
|
|
202
|
+
/**
|
|
203
|
+
* @param {SoundingSailsApp} sails
|
|
204
|
+
* @param {AnyRecord} [inputs]
|
|
205
|
+
* @param {AnyRecord} [options]
|
|
206
|
+
* @returns {Promise<SoundingMailMessage>}
|
|
207
|
+
*/
|
|
208
|
+
async function buildCapturedMail(sails, inputs = {}, options = {}) {
|
|
129
209
|
const mailer = resolveMailerName(sails, inputs)
|
|
130
210
|
const transport = resolveTransportName(sails, mailer)
|
|
131
|
-
const
|
|
211
|
+
const layout = resolvePreviewLayout(sails, inputs, options)
|
|
212
|
+
const html = await renderTemplatePreview(sails, inputs, options)
|
|
132
213
|
const links = extractLinks(html)
|
|
133
214
|
|
|
134
215
|
return {
|
|
@@ -150,10 +231,14 @@ async function buildCapturedMail(sails, inputs = {}) {
|
|
|
150
231
|
ctaUrl: resolvePrimaryLink(inputs, links),
|
|
151
232
|
attachments: inputs.attachments || [],
|
|
152
233
|
headers: inputs.headers || {},
|
|
153
|
-
layout
|
|
234
|
+
layout,
|
|
154
235
|
}
|
|
155
236
|
}
|
|
156
237
|
|
|
238
|
+
/**
|
|
239
|
+
* @param {{ sails?: SoundingSailsApp, mailbox: SoundingMailbox, getConfig?: () => AnyRecord }} input
|
|
240
|
+
* @returns {SoundingMailCapture}
|
|
241
|
+
*/
|
|
157
242
|
function createMailCapture({ sails, mailbox, getConfig }) {
|
|
158
243
|
let originalSend = null
|
|
159
244
|
|
|
@@ -164,15 +249,30 @@ function createMailCapture({ sails, mailbox, getConfig }) {
|
|
|
164
249
|
return soundingConfig.mail || {}
|
|
165
250
|
}
|
|
166
251
|
|
|
252
|
+
function resolveCaptureTarget() {
|
|
253
|
+
const context = getTrialContext()
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
mailbox: context?.mailbox || mailbox,
|
|
257
|
+
mailSettings: context?.getConfig?.().mail || resolveMailSettings(),
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
167
261
|
function isEnabled() {
|
|
168
262
|
return resolveMailSettings().capture !== false
|
|
169
263
|
}
|
|
170
264
|
|
|
171
265
|
function shouldPassthrough() {
|
|
172
|
-
const settings =
|
|
266
|
+
const settings = resolveCaptureTarget().mailSettings
|
|
173
267
|
return settings.deliver === true || settings.mode === 'passthrough'
|
|
174
268
|
}
|
|
175
269
|
|
|
270
|
+
function resolveMessageLayout(inputs = {}) {
|
|
271
|
+
return resolvePreviewLayout(sails, inputs, {
|
|
272
|
+
mailSettings: resolveCaptureTarget().mailSettings,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
176
276
|
function wrapDeferred(executor, onRejected) {
|
|
177
277
|
let promise = Promise.resolve().then(executor)
|
|
178
278
|
|
|
@@ -250,7 +350,10 @@ function createMailCapture({ sails, mailbox, getConfig }) {
|
|
|
250
350
|
return sendHelper.with.bind(sendHelper)
|
|
251
351
|
}
|
|
252
352
|
|
|
253
|
-
throw
|
|
353
|
+
throw createSoundingError({
|
|
354
|
+
code: 'E_SOUNDING_MAIL_SEND_UNAVAILABLE',
|
|
355
|
+
message: 'Sounding could not invoke `sails.helpers.mail.send`.',
|
|
356
|
+
})
|
|
254
357
|
}
|
|
255
358
|
|
|
256
359
|
function resolveWithInvoker(sendHelper) {
|
|
@@ -262,23 +365,31 @@ function createMailCapture({ sails, mailbox, getConfig }) {
|
|
|
262
365
|
return sendHelper.bind(sendHelper)
|
|
263
366
|
}
|
|
264
367
|
|
|
265
|
-
throw
|
|
368
|
+
throw createSoundingError({
|
|
369
|
+
code: 'E_SOUNDING_MAIL_SEND_WITH_UNAVAILABLE',
|
|
370
|
+
message: 'Sounding could not invoke `sails.helpers.mail.send.with()`.',
|
|
371
|
+
})
|
|
266
372
|
}
|
|
267
373
|
|
|
268
374
|
async function captureSuccessfulSend(inputs = {}) {
|
|
375
|
+
const target = resolveCaptureTarget()
|
|
376
|
+
|
|
269
377
|
try {
|
|
270
|
-
const message = await buildCapturedMail(sails, inputs
|
|
271
|
-
|
|
378
|
+
const message = await buildCapturedMail(sails, inputs, {
|
|
379
|
+
mailSettings: target.mailSettings,
|
|
380
|
+
})
|
|
381
|
+
target.mailbox.capture({
|
|
272
382
|
...message,
|
|
273
383
|
status: 'sent',
|
|
274
384
|
})
|
|
275
385
|
} catch (error) {
|
|
276
|
-
mailbox.capture({
|
|
386
|
+
target.mailbox.capture({
|
|
277
387
|
mailer: resolveMailerName(sails, inputs),
|
|
278
388
|
transport: resolveTransportName(sails, resolveMailerName(sails, inputs)),
|
|
279
389
|
to: normalizeList(inputs.to),
|
|
280
390
|
subject: inputs.subject || '',
|
|
281
391
|
template: inputs.template,
|
|
392
|
+
layout: resolveMessageLayout(inputs),
|
|
282
393
|
status: 'sent',
|
|
283
394
|
captureError: toErrorShape(error),
|
|
284
395
|
})
|
|
@@ -286,10 +397,13 @@ function createMailCapture({ sails, mailbox, getConfig }) {
|
|
|
286
397
|
}
|
|
287
398
|
|
|
288
399
|
async function captureFailedSend(inputs = {}, error) {
|
|
400
|
+
const target = resolveCaptureTarget()
|
|
289
401
|
let message
|
|
290
402
|
|
|
291
403
|
try {
|
|
292
|
-
message = await buildCapturedMail(sails, inputs
|
|
404
|
+
message = await buildCapturedMail(sails, inputs, {
|
|
405
|
+
mailSettings: target.mailSettings,
|
|
406
|
+
})
|
|
293
407
|
} catch (captureError) {
|
|
294
408
|
message = {
|
|
295
409
|
mailer: resolveMailerName(sails, inputs),
|
|
@@ -297,11 +411,12 @@ function createMailCapture({ sails, mailbox, getConfig }) {
|
|
|
297
411
|
to: normalizeList(inputs.to),
|
|
298
412
|
subject: inputs.subject || '',
|
|
299
413
|
template: inputs.template,
|
|
414
|
+
layout: resolveMessageLayout(inputs),
|
|
300
415
|
captureError: toErrorShape(captureError),
|
|
301
416
|
}
|
|
302
417
|
}
|
|
303
418
|
|
|
304
|
-
mailbox.capture({
|
|
419
|
+
target.mailbox.capture({
|
|
305
420
|
...message,
|
|
306
421
|
status: 'failed',
|
|
307
422
|
error: toErrorShape(error),
|
|
@@ -347,27 +462,60 @@ function createMailCapture({ sails, mailbox, getConfig }) {
|
|
|
347
462
|
return false
|
|
348
463
|
}
|
|
349
464
|
|
|
350
|
-
const
|
|
465
|
+
const mailHelpers = sails?.helpers?.mail
|
|
466
|
+
const sendHelper = mailHelpers?.send
|
|
351
467
|
if (!sendHelper) {
|
|
352
468
|
return false
|
|
353
469
|
}
|
|
354
470
|
|
|
355
|
-
|
|
356
|
-
|
|
471
|
+
const registered = mailCaptureRegistry.get(mailHelpers) || sendHelper.__soundingRegistry
|
|
472
|
+
if (registered && sendHelper === registered.wrappedSend) {
|
|
473
|
+
registered.installs += 1
|
|
474
|
+
originalSend = registered.originalSend
|
|
475
|
+
mailCaptureRegistry.set(mailHelpers, registered)
|
|
357
476
|
return true
|
|
358
477
|
}
|
|
359
478
|
|
|
360
479
|
originalSend = sendHelper
|
|
361
|
-
|
|
480
|
+
const wrappedSend = wrapSendHelper(sendHelper)
|
|
481
|
+
const registry = {
|
|
482
|
+
originalSend,
|
|
483
|
+
wrappedSend,
|
|
484
|
+
installs: 1,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
Object.defineProperty(wrappedSend, '__soundingRegistry', {
|
|
488
|
+
value: registry,
|
|
489
|
+
configurable: true,
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
mailCaptureRegistry.set(mailHelpers, registry)
|
|
493
|
+
mailHelpers.send = wrappedSend
|
|
362
494
|
return true
|
|
363
495
|
}
|
|
364
496
|
|
|
365
497
|
function uninstall() {
|
|
366
|
-
|
|
498
|
+
const mailHelpers = sails?.helpers?.mail
|
|
499
|
+
|
|
500
|
+
if (!originalSend || !mailHelpers) {
|
|
367
501
|
return false
|
|
368
502
|
}
|
|
369
503
|
|
|
370
|
-
|
|
504
|
+
const registered = mailCaptureRegistry.get(mailHelpers)
|
|
505
|
+
|
|
506
|
+
if (registered && registered.originalSend === originalSend) {
|
|
507
|
+
registered.installs -= 1
|
|
508
|
+
|
|
509
|
+
if (registered.installs <= 0) {
|
|
510
|
+
mailHelpers.send = originalSend
|
|
511
|
+
mailCaptureRegistry.delete(mailHelpers)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
originalSend = null
|
|
515
|
+
return true
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
mailHelpers.send = originalSend
|
|
371
519
|
originalSend = null
|
|
372
520
|
return true
|
|
373
521
|
}
|
|
@@ -388,4 +536,5 @@ module.exports = {
|
|
|
388
536
|
normalizeList,
|
|
389
537
|
resolvePrimaryLink,
|
|
390
538
|
renderTemplatePreview,
|
|
539
|
+
resolvePreviewLayout,
|
|
391
540
|
}
|
package/lib/create-mailbox.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
/** @typedef {import('./types').SoundingMailbox} SoundingMailbox */
|
|
2
|
+
/** @typedef {import('./types').SoundingMailMessage} SoundingMailMessage */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @returns {SoundingMailbox}
|
|
6
|
+
*/
|
|
1
7
|
function createMailbox() {
|
|
8
|
+
/** @type {SoundingMailMessage[]} */
|
|
2
9
|
const messages = []
|
|
3
10
|
|
|
4
11
|
return {
|
|
12
|
+
/**
|
|
13
|
+
* @param {SoundingMailMessage} message
|
|
14
|
+
* @returns {SoundingMailMessage}
|
|
15
|
+
*/
|
|
5
16
|
capture(message) {
|
|
6
17
|
const normalized = {
|
|
7
18
|
capturedAt: new Date().toISOString(),
|
|
@@ -11,14 +22,23 @@ function createMailbox() {
|
|
|
11
22
|
return normalized
|
|
12
23
|
},
|
|
13
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @returns {SoundingMailMessage[]}
|
|
27
|
+
*/
|
|
14
28
|
all() {
|
|
15
29
|
return [...messages]
|
|
16
30
|
},
|
|
17
31
|
|
|
32
|
+
/**
|
|
33
|
+
* @returns {SoundingMailMessage | undefined}
|
|
34
|
+
*/
|
|
18
35
|
latest() {
|
|
19
36
|
return messages.at(-1)
|
|
20
37
|
},
|
|
21
38
|
|
|
39
|
+
/**
|
|
40
|
+
* @returns {void}
|
|
41
|
+
*/
|
|
22
42
|
clear() {
|
|
23
43
|
messages.length = 0
|
|
24
44
|
},
|