automacao-core-carga-back 1.0.2
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 +348 -0
- package/dist/k6.cjs +403 -0
- package/dist/k6.mjs +389 -0
- package/dist/playwright.cjs +766 -0
- package/dist/playwright.mjs +750 -0
- package/package.json +73 -0
- package/src/types/k6.d.ts +136 -0
- package/src/types/playwright.d.ts +220 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var fs = require('node:fs');
|
|
6
|
+
var path = require('node:path');
|
|
7
|
+
var test$1 = require('@playwright/test');
|
|
8
|
+
var automacaoCorePlaywright = require('automacao-core-playwright');
|
|
9
|
+
var sharp = require('sharp');
|
|
10
|
+
|
|
11
|
+
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
|
12
|
+
|
|
13
|
+
var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs);
|
|
14
|
+
var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
|
|
15
|
+
var sharp__default = /*#__PURE__*/_interopDefaultLegacy(sharp);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `true` se o arquivo existe e não está vazio.
|
|
19
|
+
* @param {string} caminho - Caminho completo do arquivo
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
function arquivoNaoVazio(caminho) {
|
|
23
|
+
try {
|
|
24
|
+
return fs__default["default"].existsSync(caminho) && fs__default["default"].statSync(caminho).size > 0;
|
|
25
|
+
} catch (_) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Page Object com interações genéricas compartilhadas entre todos os specs
|
|
32
|
+
* de teste de carga: login APM/Grafana, capturas de tela e combinação de imagens.
|
|
33
|
+
*/
|
|
34
|
+
class ComunsUtils {
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('@playwright/test').Page} page
|
|
37
|
+
*/
|
|
38
|
+
constructor(page) {
|
|
39
|
+
this.page = page;
|
|
40
|
+
this.dataUtils = new automacaoCorePlaywright.DataUtils(page);
|
|
41
|
+
this.formUtils = new automacaoCorePlaywright.FormUtils(page);
|
|
42
|
+
this.navigationUtils = new automacaoCorePlaywright.NavigationUtils(page);
|
|
43
|
+
const now = new Date();
|
|
44
|
+
this.ano = now.getFullYear();
|
|
45
|
+
this.mes = String(now.getMonth() + 1).padStart(2, '0');
|
|
46
|
+
this.dia = String(now.getDate()).padStart(2, '0');
|
|
47
|
+
|
|
48
|
+
// Locators — Grafana
|
|
49
|
+
this.locatorBemVindoGrafanaHeader = page.getByRole('heading', {
|
|
50
|
+
name: 'Welcome to Grafana'
|
|
51
|
+
});
|
|
52
|
+
this.locatorUsernameInput = page.getByTestId('data-testid Username input field');
|
|
53
|
+
this.locatorSenhaInput = page.getByTestId('data-testid Password input field');
|
|
54
|
+
this.locatorLoginButton = page.getByTestId('data-testid Login button');
|
|
55
|
+
|
|
56
|
+
// Locators — APM (Elastic)
|
|
57
|
+
this.locatorLoadingElasticText = page.getByText('Loading Elastic');
|
|
58
|
+
this.locatorBemVindoElasticHeader = page.getByRole('heading', {
|
|
59
|
+
name: 'Welcome to Elastic'
|
|
60
|
+
});
|
|
61
|
+
this.locatorUsuarioInput = page.getByRole('textbox', {
|
|
62
|
+
name: 'Username'
|
|
63
|
+
});
|
|
64
|
+
this.locatorPasswordInput = page.getByRole('textbox', {
|
|
65
|
+
name: 'Password'
|
|
66
|
+
});
|
|
67
|
+
this.locatorLoginButtonApm = page.getByRole('button', {
|
|
68
|
+
name: 'Log in'
|
|
69
|
+
});
|
|
70
|
+
this.locatorCarregandoElasticText = page.getByText('Loading Elastic');
|
|
71
|
+
this.locatorElasticHomePage = page.getByRole('link', {
|
|
72
|
+
name: 'Elastic home'
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Aguarda N milissegundos — sempre o tempo cheio.
|
|
78
|
+
*
|
|
79
|
+
* Prefira `aguardarAte` quando houver uma condição observável: `delay` gasta o
|
|
80
|
+
* tempo todo mesmo quando a tela já está pronta.
|
|
81
|
+
*
|
|
82
|
+
* @param {number} ms
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
*/
|
|
85
|
+
async delay(ms) {
|
|
86
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Aguarda até `condicao` retornar verdadeiro, verificando a cada `intervalo`.
|
|
91
|
+
* Retorna assim que a condição é satisfeita, sem gastar o tempo restante.
|
|
92
|
+
*
|
|
93
|
+
* Não lança no timeout: devolve `false` e deixa a decisão para quem chamou.
|
|
94
|
+
* Isso preserva a semântica de "melhor esforço" das esperas de acomodação que
|
|
95
|
+
* este método substituiu, sem o custo fixo quando a tela já está pronta.
|
|
96
|
+
*
|
|
97
|
+
* @param {() => (boolean | Promise<boolean>)} condicao - Verificação; exceção conta como não satisfeita
|
|
98
|
+
* @param {Object} [opcoes]
|
|
99
|
+
* @param {number} [opcoes.intervalo=100] - Intervalo entre verificações em ms
|
|
100
|
+
* @param {number} [opcoes.timeout=10000] - Tempo máximo de espera em ms
|
|
101
|
+
* @returns {Promise<boolean>} `true` se satisfeita; `false` se estourou o timeout
|
|
102
|
+
* @example
|
|
103
|
+
* const pronto = await page.comunsUtils.aguardarAte(
|
|
104
|
+
* () => page.locator('#painel').isVisible(),
|
|
105
|
+
* { timeout: 2000 },
|
|
106
|
+
* );
|
|
107
|
+
*/
|
|
108
|
+
async aguardarAte(condicao, opcoes = {}) {
|
|
109
|
+
const {
|
|
110
|
+
intervalo = 100,
|
|
111
|
+
timeout = 10000
|
|
112
|
+
} = opcoes;
|
|
113
|
+
const limite = Date.now() + timeout;
|
|
114
|
+
for (;;) {
|
|
115
|
+
try {
|
|
116
|
+
if (await condicao()) return true;
|
|
117
|
+
} catch (_) {/* elemento ausente ou página navegando: conta como não satisfeita */}
|
|
118
|
+
const restante = limite - Date.now();
|
|
119
|
+
if (restante <= 0) return false;
|
|
120
|
+
await this.delay(Math.min(intervalo, restante));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
// Geração de URL com data dinâmica
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Atualiza os parâmetros de intervalo de tempo de uma URL com horários locais
|
|
130
|
+
* convertidos para ISO UTC (offset +3h para compensar UTC-3 Brasil).
|
|
131
|
+
* @param {string} baseUrl - URL base a ser atualizada
|
|
132
|
+
* @param {string} horarioInicial - Horário de início no formato "HH:mm:ss"
|
|
133
|
+
* @param {string} horarioFinal - Horário de fim no formato "HH:mm:ss"
|
|
134
|
+
* @param {string} paramInicio - Nome do query param de início (ex: 'rangeFrom' ou 'from')
|
|
135
|
+
* @param {string} paramFim - Nome do query param de fim (ex: 'rangeTo' ou 'to')
|
|
136
|
+
* @param {string|null} [data=null] - Data no formato "DD-MM-YYYY"; se null usa a data atual
|
|
137
|
+
* @returns {string} URL com os parâmetros de intervalo atualizados
|
|
138
|
+
* @private
|
|
139
|
+
*/
|
|
140
|
+
_gerarUrlComDataAtual(baseUrl, horarioInicial, horarioFinal, paramInicio, paramFim, data = null) {
|
|
141
|
+
let ano, dia, mes;
|
|
142
|
+
if (data) {
|
|
143
|
+
const partes = data.split('-');
|
|
144
|
+
dia = partes[0];
|
|
145
|
+
mes = partes[1];
|
|
146
|
+
ano = partes[2];
|
|
147
|
+
} else {
|
|
148
|
+
ano = this.ano;
|
|
149
|
+
mes = this.mes;
|
|
150
|
+
dia = this.dia;
|
|
151
|
+
}
|
|
152
|
+
const parseHorario = horario => {
|
|
153
|
+
const partes = horario.split(':');
|
|
154
|
+
const hora = String(parseInt(partes[0]) + 3).padStart(2, '0'); // UTC-3 → UTC
|
|
155
|
+
const min = partes[1].padStart(2, '0');
|
|
156
|
+
const seg = (partes[2] ? partes[2].split('.')[0] : '00').padStart(2, '0');
|
|
157
|
+
return `${ano}-${mes}-${dia}T${hora}:${min}:${seg}.000Z`;
|
|
158
|
+
};
|
|
159
|
+
const urlAtualizada = baseUrl.replace(new RegExp(`[&?]${paramInicio}=[^&]*`), '').replace(new RegExp(`[&?]${paramFim}=[^&]*`), '');
|
|
160
|
+
const sep = urlAtualizada.includes('?') ? '&' : '?';
|
|
161
|
+
return `${urlAtualizada}${sep}${paramInicio}=${encodeURIComponent(parseHorario(horarioInicial))}&${paramFim}=${encodeURIComponent(parseHorario(horarioFinal))}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Gera URL do APM com intervalo de tempo atualizado para o dia atual.
|
|
166
|
+
* Atualiza os parâmetros `rangeFrom` e `rangeTo`.
|
|
167
|
+
* @param {string} baseUrl - URL base do APM
|
|
168
|
+
* @param {string} horarioInicial - Ex: "09:40:00"
|
|
169
|
+
* @param {string} horarioFinal - Ex: "10:50:00"
|
|
170
|
+
* @param {string|null} [data=null] - Data no formato "DD-MM-YYYY" (opcional)
|
|
171
|
+
* @returns {string} URL atualizada
|
|
172
|
+
*/
|
|
173
|
+
gerarUrlApmComDataAtual(baseUrl, horarioInicial, horarioFinal, data = null) {
|
|
174
|
+
return this._gerarUrlComDataAtual(baseUrl, horarioInicial, horarioFinal, 'rangeFrom', 'rangeTo', data);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Gera URL do Grafana com intervalo de tempo atualizado para o dia atual.
|
|
179
|
+
* Atualiza os parâmetros `from` e `to`.
|
|
180
|
+
* @param {string} baseUrl - URL base do Grafana
|
|
181
|
+
* @param {string} horarioInicial - Ex: "10:37:00"
|
|
182
|
+
* @param {string} horarioFinal - Ex: "11:11:00"
|
|
183
|
+
* @param {string|null} [data=null] - Data no formato "DD-MM-YYYY" (opcional)
|
|
184
|
+
* @returns {string} URL atualizada
|
|
185
|
+
*/
|
|
186
|
+
gerarUrlGrafanaComDataAtual(baseUrl, horarioInicial, horarioFinal, data = null) {
|
|
187
|
+
return this._gerarUrlComDataAtual(baseUrl, horarioInicial, horarioFinal, 'from', 'to', data);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
191
|
+
// Login
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Realiza login no Grafana.
|
|
196
|
+
* @param {Object} usuario - { email: string, senha: string }
|
|
197
|
+
*/
|
|
198
|
+
async loginGrafana(usuario) {
|
|
199
|
+
await this.page.goto('https://grafana.senior.com.br/login');
|
|
200
|
+
await test$1.expect(this.locatorBemVindoGrafanaHeader).toBeVisible();
|
|
201
|
+
await this.locatorUsernameInput.type(usuario.email);
|
|
202
|
+
await this.locatorSenhaInput.type(usuario.senha);
|
|
203
|
+
await test$1.expect(this.locatorUsernameInput).toHaveValue(usuario.email);
|
|
204
|
+
await test$1.expect(this.locatorSenhaInput).toHaveValue(usuario.senha);
|
|
205
|
+
await this.locatorBemVindoGrafanaHeader.click();
|
|
206
|
+
await test$1.expect(this.locatorLoginButton).toBeVisible();
|
|
207
|
+
await this.locatorLoginButton.click();
|
|
208
|
+
await this.page.getByText('Loading').waitFor({
|
|
209
|
+
state: 'visible'
|
|
210
|
+
});
|
|
211
|
+
console.log(`Usuário ${usuario.email} logado no Grafana`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Realiza login no APM Elastic.
|
|
216
|
+
* @param {Object} usuario - { usuario: string, senha: string }
|
|
217
|
+
*/
|
|
218
|
+
async loginApm(usuario) {
|
|
219
|
+
await this.page.goto('https://apm.platform-homologx.senior.com.br:8443/login');
|
|
220
|
+
await test$1.expect(this.locatorLoadingElasticText).toBeVisible();
|
|
221
|
+
await test$1.expect(this.locatorBemVindoElasticHeader).toBeVisible();
|
|
222
|
+
await this.locatorUsuarioInput.type(usuario.usuario);
|
|
223
|
+
await this.locatorPasswordInput.type(usuario.senha);
|
|
224
|
+
await test$1.expect(this.locatorUsuarioInput).toHaveValue(usuario.usuario);
|
|
225
|
+
await test$1.expect(this.locatorPasswordInput).toHaveValue(usuario.senha);
|
|
226
|
+
await test$1.expect(this.locatorLoginButtonApm).toBeVisible();
|
|
227
|
+
await this.locatorLoginButtonApm.click();
|
|
228
|
+
await test$1.expect(this.locatorBemVindoElasticHeader).toBeHidden();
|
|
229
|
+
await test$1.expect(this.locatorCarregandoElasticText).toBeVisible();
|
|
230
|
+
await test$1.expect(this.locatorCarregandoElasticText).toBeHidden();
|
|
231
|
+
await test$1.expect(this.locatorElasticHomePage).toBeVisible();
|
|
232
|
+
console.log(`Usuário ${usuario.usuario} logado no APM`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
// Screenshots
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Captura screenshot da página inteira com retry automático (3 tentativas).
|
|
241
|
+
* Cria o diretório de destino se não existir.
|
|
242
|
+
* @param {string} nomeArquivo - Nome do arquivo sem extensão
|
|
243
|
+
* @param {string} [caminhoCustomizado='playwright/resources/imagens'] - Diretório de destino
|
|
244
|
+
*/
|
|
245
|
+
async tirarPrintTelaInteira(nomeArquivo, caminhoCustomizado = 'playwright/resources/imagens') {
|
|
246
|
+
try {
|
|
247
|
+
await this.page.waitForLoadState('networkidle', {
|
|
248
|
+
timeout: 15000
|
|
249
|
+
});
|
|
250
|
+
} catch (_) {/* ignora timeout — a página pode ter requests de longa duração */}
|
|
251
|
+
await this.page.evaluate(() => window.scrollTo(0, 0));
|
|
252
|
+
await this.aguardarAte(() => this.page.evaluate(() => window.scrollY === 0), {
|
|
253
|
+
timeout: 2000
|
|
254
|
+
});
|
|
255
|
+
const caminhoCompleto = `${caminhoCustomizado}/${nomeArquivo}.png`;
|
|
256
|
+
const diretorio = path__default["default"].dirname(caminhoCompleto);
|
|
257
|
+
if (!fs__default["default"].existsSync(diretorio)) {
|
|
258
|
+
fs__default["default"].mkdirSync(diretorio, {
|
|
259
|
+
recursive: true
|
|
260
|
+
});
|
|
261
|
+
console.log(`Diretório criado: ${diretorio}`);
|
|
262
|
+
}
|
|
263
|
+
const maxTentativas = 3;
|
|
264
|
+
let screenshotCriada = false;
|
|
265
|
+
for (let tentativa = 1; tentativa <= maxTentativas && !screenshotCriada; tentativa++) {
|
|
266
|
+
try {
|
|
267
|
+
await this.page.screenshot({
|
|
268
|
+
path: caminhoCompleto,
|
|
269
|
+
fullPage: true,
|
|
270
|
+
animations: 'disabled'
|
|
271
|
+
});
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error.message.includes('Cannot take screenshot larger than 32767')) {
|
|
274
|
+
const vp = this.page.viewportSize();
|
|
275
|
+
await this.page.screenshot({
|
|
276
|
+
path: caminhoCompleto,
|
|
277
|
+
clip: {
|
|
278
|
+
x: 0,
|
|
279
|
+
y: 0,
|
|
280
|
+
width: vp.width,
|
|
281
|
+
height: 32760
|
|
282
|
+
},
|
|
283
|
+
animations: 'disabled'
|
|
284
|
+
});
|
|
285
|
+
} else if (['Unable to capture screenshot', 'Protocol error', 'Target closed', 'Session closed'].some(m => error.message.includes(m))) {
|
|
286
|
+
console.log(`⚠ Erro transitório (tentativa ${tentativa}/${maxTentativas}): ${error.message}`);
|
|
287
|
+
await this.aguardarAte(() => this.page.evaluate(() => true), {
|
|
288
|
+
timeout: 2000
|
|
289
|
+
});
|
|
290
|
+
try {
|
|
291
|
+
await this.page.waitForLoadState('networkidle', {
|
|
292
|
+
timeout: 10000
|
|
293
|
+
});
|
|
294
|
+
} catch (_) {/* ignora */}
|
|
295
|
+
} else {
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
await this.aguardarAte(() => arquivoNaoVazio(caminhoCompleto), {
|
|
300
|
+
intervalo: 50,
|
|
301
|
+
timeout: 500
|
|
302
|
+
});
|
|
303
|
+
if (arquivoNaoVazio(caminhoCompleto)) {
|
|
304
|
+
screenshotCriada = true;
|
|
305
|
+
console.log(`✓ Screenshot salva (${fs__default["default"].statSync(caminhoCompleto).size} bytes): ${caminhoCompleto}`);
|
|
306
|
+
} else {
|
|
307
|
+
console.log(`⚠ Screenshot não gerada, tentando novamente (${tentativa}/${maxTentativas})...`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (!screenshotCriada) {
|
|
311
|
+
throw new Error(`Falha ao criar screenshot após ${maxTentativas} tentativas: ${caminhoCompleto}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Captura screenshot de um elemento específico via locator.
|
|
317
|
+
* @param {import('@playwright/test').Locator} locator - Locator do elemento
|
|
318
|
+
* @param {string} nomeArquivo - Nome do arquivo sem extensão
|
|
319
|
+
* @param {string} [caminhoCustomizado='playwright/resources/imagens'] - Diretório de destino
|
|
320
|
+
*/
|
|
321
|
+
async tirarPrintLocator(locator, nomeArquivo, caminhoCustomizado = 'playwright/resources/imagens') {
|
|
322
|
+
await locator.scrollIntoViewIfNeeded();
|
|
323
|
+
await test$1.expect(locator).toBeVisible();
|
|
324
|
+
const caminhoCompleto = `${caminhoCustomizado}/${nomeArquivo}.png`;
|
|
325
|
+
await locator.screenshot({
|
|
326
|
+
path: caminhoCompleto,
|
|
327
|
+
animations: 'disabled'
|
|
328
|
+
});
|
|
329
|
+
console.log(`Screenshot do elemento salva: ${caminhoCompleto}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Combina múltiplas screenshots em uma única imagem vertical usando sharp.
|
|
334
|
+
* @param {string[]} nomesImagens - Nomes dos arquivos originais (sem extensão)
|
|
335
|
+
* @param {string} nomeArquivoFinal - Nome do arquivo combinado (sem extensão)
|
|
336
|
+
* @param {string} [caminhoOriginais='playwright/resources/imagens'] - Diretório das imagens originais
|
|
337
|
+
* @param {boolean} [deletarOriginais=false] - Se true, remove os arquivos originais após combinar
|
|
338
|
+
* @param {string|null} [caminhoDestino=null] - Diretório destino; se null usa caminhoOriginais
|
|
339
|
+
* @returns {Promise<string[]>} Caminhos das imagens individuais
|
|
340
|
+
* @example
|
|
341
|
+
* await page.comunsUtils.combinarPrints(
|
|
342
|
+
* ['bridge', 'prepareInvoice'],
|
|
343
|
+
* 'bridge-completo',
|
|
344
|
+
* 'playwright/resources/imagens/faturamento',
|
|
345
|
+
* true,
|
|
346
|
+
* pathReports,
|
|
347
|
+
* );
|
|
348
|
+
*/
|
|
349
|
+
async combinarPrints(nomesImagens, nomeArquivoFinal, caminhoOriginais = 'playwright/resources/imagens', deletarOriginais = false, caminhoDestino = null) {
|
|
350
|
+
const caminhoSalvar = caminhoDestino || caminhoOriginais;
|
|
351
|
+
if (!fs__default["default"].existsSync(caminhoSalvar)) {
|
|
352
|
+
fs__default["default"].mkdirSync(caminhoSalvar, {
|
|
353
|
+
recursive: true
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
const caminhosImagens = nomesImagens.map(n => path__default["default"].join(caminhoOriginais, `${n}.png`));
|
|
357
|
+
const caminhoFinal = path__default["default"].join(caminhoSalvar, `${nomeArquivoFinal}.png`);
|
|
358
|
+
for (const c of caminhosImagens) {
|
|
359
|
+
if (!fs__default["default"].existsSync(c)) throw new Error(`Imagem não encontrada: ${c}`);
|
|
360
|
+
}
|
|
361
|
+
const metadados = await Promise.all(caminhosImagens.map(c => sharp__default["default"](c).metadata()));
|
|
362
|
+
const larguraMaxima = Math.max(...metadados.map(m => m.width));
|
|
363
|
+
const alturaTotal = metadados.reduce((s, m) => s + m.height, 0);
|
|
364
|
+
let posY = 0;
|
|
365
|
+
const composicoes = caminhosImagens.map((input, i) => {
|
|
366
|
+
const comp = {
|
|
367
|
+
input,
|
|
368
|
+
top: posY,
|
|
369
|
+
left: 0
|
|
370
|
+
};
|
|
371
|
+
posY += metadados[i].height;
|
|
372
|
+
return comp;
|
|
373
|
+
});
|
|
374
|
+
await sharp__default["default"]({
|
|
375
|
+
create: {
|
|
376
|
+
width: larguraMaxima,
|
|
377
|
+
height: alturaTotal,
|
|
378
|
+
channels: 4,
|
|
379
|
+
background: {
|
|
380
|
+
r: 255,
|
|
381
|
+
g: 255,
|
|
382
|
+
b: 255,
|
|
383
|
+
alpha: 1
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}).composite(composicoes).toFile(caminhoFinal);
|
|
387
|
+
console.log(`Imagem combinada salva: ${caminhoFinal}`);
|
|
388
|
+
if (deletarOriginais) {
|
|
389
|
+
for (const c of caminhosImagens) {
|
|
390
|
+
fs__default["default"].unlinkSync(c);
|
|
391
|
+
console.log(`Original deletado: ${c}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return caminhosImagens;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const CSS_EUIACCORDIONBUTTON = 'button.euiAccordion__button';
|
|
399
|
+
const CSS_EUIICON_SVG = 'svg.euiIcon';
|
|
400
|
+
const CSS_PATHSETATRACE = 'path[d*="M1.957 4.982"]';
|
|
401
|
+
const CSS_EUIFLEXGROUP = '.euiFlexGroup.css-z23vzb-euiFlexGroup-xs-flexStart-stretch-column';
|
|
402
|
+
const REGEX_LOADING = /^Loading…$/;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Page Object para interações com o APM Elastic (Application Performance Monitoring).
|
|
406
|
+
* Encapsula navegação entre serviços, transactions e traces.
|
|
407
|
+
*/
|
|
408
|
+
class ApmUtils {
|
|
409
|
+
/**
|
|
410
|
+
* @param {import('@playwright/test').Page} page
|
|
411
|
+
*/
|
|
412
|
+
constructor(page) {
|
|
413
|
+
this.page = page;
|
|
414
|
+
this.comunsUtils = new ComunsUtils(page);
|
|
415
|
+
this.locatorElasticHome = page.getByRole('link', {
|
|
416
|
+
name: 'Elastic home'
|
|
417
|
+
});
|
|
418
|
+
this.locatorServicesHeader = page.getByText('Loaded Services');
|
|
419
|
+
this.locatorCarregamentoServices = page.locator('div').filter({
|
|
420
|
+
hasText: REGEX_LOADING
|
|
421
|
+
});
|
|
422
|
+
this.locatorCarregamentoInterno = page.getByRole('progressbar', {
|
|
423
|
+
name: 'Loading'
|
|
424
|
+
}).nth(2);
|
|
425
|
+
this.locatorTabelaText = page.locator(CSS_EUIFLEXGROUP);
|
|
426
|
+
this.locatorDismiss = page.getByRole('button', {
|
|
427
|
+
name: 'Dismiss',
|
|
428
|
+
exact: true
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Navega para a URL do APM e aguarda o carregamento completo da lista de serviços.
|
|
434
|
+
* Clica no botão "Dismiss" se estiver visível.
|
|
435
|
+
* @param {string} url - URL do APM
|
|
436
|
+
*/
|
|
437
|
+
async acessaTelaApm(url) {
|
|
438
|
+
await this.page.goto(url);
|
|
439
|
+
await this.page.waitForLoadState('networkidle');
|
|
440
|
+
await test$1.expect(this.locatorElasticHome).toBeVisible();
|
|
441
|
+
await test$1.expect(this.locatorServicesHeader).toBeVisible();
|
|
442
|
+
await test$1.expect(this.locatorCarregamentoServices).toBeHidden({
|
|
443
|
+
timeout: 180000
|
|
444
|
+
});
|
|
445
|
+
try {
|
|
446
|
+
await test$1.expect(this.locatorDismiss).toBeVisible({
|
|
447
|
+
timeout: 3000
|
|
448
|
+
});
|
|
449
|
+
await this.locatorDismiss.click();
|
|
450
|
+
console.log('Botão Dismiss clicado');
|
|
451
|
+
} catch (_) {
|
|
452
|
+
console.log('Botão Dismiss não encontrado, prosseguindo...');
|
|
453
|
+
}
|
|
454
|
+
console.log('Tela APM carregada com sucesso');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Acessa os detalhes de um serviço na lista do APM.
|
|
459
|
+
* Aguarda `detailed_statistics` e `dependencies` antes de prosseguir.
|
|
460
|
+
* Faz até 3 tentativas com reload em caso de falha.
|
|
461
|
+
* @param {string} nomeServico - Ex: 'bridge', 'erpx-fin-contas-pagar'
|
|
462
|
+
*/
|
|
463
|
+
async acessarDetalhesServico(nomeServico) {
|
|
464
|
+
const servicoLink = this.page.getByRole('link', {
|
|
465
|
+
name: `opentelemetry/java ${nomeServico}`,
|
|
466
|
+
exact: true
|
|
467
|
+
});
|
|
468
|
+
const headerServico = this.page.locator('main').getByText(nomeServico, {
|
|
469
|
+
exact: true
|
|
470
|
+
}).first();
|
|
471
|
+
const maxTentativas = 3;
|
|
472
|
+
for (let t = 1; t <= maxTentativas; t++) {
|
|
473
|
+
try {
|
|
474
|
+
await test$1.expect(servicoLink).toBeVisible();
|
|
475
|
+
const statsPromise = this.page.waitForResponse(r => r.url().includes('/detailed_statistics') && r.status() === 200, {
|
|
476
|
+
timeout: 60000
|
|
477
|
+
});
|
|
478
|
+
const depsPromise = this.page.waitForResponse(r => r.url().includes('/dependencies') && r.status() === 200, {
|
|
479
|
+
timeout: 60000
|
|
480
|
+
});
|
|
481
|
+
await servicoLink.click();
|
|
482
|
+
await this.page.waitForLoadState('networkidle');
|
|
483
|
+
await test$1.expect(this.locatorCarregamentoInterno).toBeVisible();
|
|
484
|
+
await test$1.expect(this.locatorCarregamentoInterno).toBeHidden();
|
|
485
|
+
await Promise.all([statsPromise, depsPromise]);
|
|
486
|
+
await test$1.expect(headerServico).toBeVisible({
|
|
487
|
+
timeout: 30000
|
|
488
|
+
});
|
|
489
|
+
console.log(`Serviço acessado: ${nomeServico}`);
|
|
490
|
+
return;
|
|
491
|
+
} catch (error) {
|
|
492
|
+
console.log(`Tentativa ${t}/${maxTentativas} para "${nomeServico}" falhou.`);
|
|
493
|
+
if (t === maxTentativas) {
|
|
494
|
+
throw new Error(`Não foi possível acessar "${nomeServico}" após ${maxTentativas} tentativas. ${error.message}`);
|
|
495
|
+
}
|
|
496
|
+
await this.page.reload();
|
|
497
|
+
await this.page.waitForLoadState('networkidle');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Acessa os detalhes de uma transaction específica dentro de um serviço.
|
|
504
|
+
* Aguarda `transactions`, `traces/` e `samples?` antes de prosseguir.
|
|
505
|
+
* @param {string} nomeTransaction - Ex: 'POST /erpx_fin/contas_receber/queries/gerarBaixas', 'calculaImpostos'
|
|
506
|
+
* @param {string} [traceExpandido='Não'] - 'Não' expande o trace; 'Sim' apenas valida o título
|
|
507
|
+
*/
|
|
508
|
+
async acessarDetalhesTransactions(nomeTransaction, traceExpandido = 'Não') {
|
|
509
|
+
this.locatorHeaderTransaction = this.page.locator('h2').filter({
|
|
510
|
+
hasText: nomeTransaction
|
|
511
|
+
});
|
|
512
|
+
this.locatorTransactionLink = this.page.getByRole('link', {
|
|
513
|
+
name: nomeTransaction,
|
|
514
|
+
exact: true
|
|
515
|
+
});
|
|
516
|
+
this.locatorAccordionButton = this.page.locator(CSS_EUIACCORDIONBUTTON).filter({
|
|
517
|
+
has: this.page.locator('h5', {
|
|
518
|
+
hasText: nomeTransaction,
|
|
519
|
+
exact: true
|
|
520
|
+
})
|
|
521
|
+
});
|
|
522
|
+
this.locatorExpandirTrace = this.locatorAccordionButton.locator(CSS_EUIICON_SVG).locator('..').filter({
|
|
523
|
+
has: this.page.locator(CSS_PATHSETATRACE)
|
|
524
|
+
}).locator('svg').first();
|
|
525
|
+
await test$1.expect(this.locatorTransactionLink).toBeVisible();
|
|
526
|
+
await this.locatorTransactionLink.click();
|
|
527
|
+
const transactionsPromise = this.page.waitForResponse(r => r.url().includes('/transactions') && r.status() === 200);
|
|
528
|
+
const tracesPromise = this.page.waitForResponse(r => r.url().includes('/traces/') && r.status() === 200);
|
|
529
|
+
const samplePromise = this.page.waitForResponse(r => r.url().includes('/samples?') && r.status() === 200);
|
|
530
|
+
await this.page.waitForLoadState('networkidle');
|
|
531
|
+
await Promise.all([transactionsPromise, samplePromise, tracesPromise]);
|
|
532
|
+
await test$1.expect(this.locatorHeaderTransaction).toBeVisible();
|
|
533
|
+
console.log(`Transaction acessada: ${nomeTransaction}`);
|
|
534
|
+
if (traceExpandido === 'Não') {
|
|
535
|
+
try {
|
|
536
|
+
await test$1.expect(this.locatorExpandirTrace).toBeVisible({
|
|
537
|
+
timeout: 120000
|
|
538
|
+
});
|
|
539
|
+
} catch (_) {
|
|
540
|
+
console.log('Botão expandir trace não ficou visível. Recarregando...');
|
|
541
|
+
await this.page.reload();
|
|
542
|
+
await this.page.waitForLoadState('networkidle');
|
|
543
|
+
await test$1.expect(this.locatorHeaderTransaction).toBeVisible();
|
|
544
|
+
await test$1.expect(this.locatorExpandirTrace).toBeVisible({
|
|
545
|
+
timeout: 120000
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
await this.locatorExpandirTrace.click();
|
|
549
|
+
} else {
|
|
550
|
+
const traceTitle = this.page.locator('h5.euiTitle').filter({
|
|
551
|
+
hasText: nomeTransaction,
|
|
552
|
+
exact: true
|
|
553
|
+
}).first();
|
|
554
|
+
await test$1.expect(traceTitle).toBeVisible({
|
|
555
|
+
timeout: 120000
|
|
556
|
+
});
|
|
557
|
+
console.log(`Título do trace "${nomeTransaction}" validado`);
|
|
558
|
+
}
|
|
559
|
+
await this.comunsUtils.aguardarAte(async () => !(await this.page.getByRole('progressbar').first().isVisible()), {
|
|
560
|
+
timeout: 1000
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Navega entre abas/seções do APM.
|
|
566
|
+
* Se nomeAba === 'Services', aguarda a resposta do endpoint de serviços.
|
|
567
|
+
* @param {string} nomeAba - Ex: 'Services', 'bridge'
|
|
568
|
+
*/
|
|
569
|
+
async navegarTelaAPM(nomeAba) {
|
|
570
|
+
const link = this.page.getByRole('link', {
|
|
571
|
+
name: nomeAba,
|
|
572
|
+
exact: true
|
|
573
|
+
});
|
|
574
|
+
if (nomeAba === 'Services') {
|
|
575
|
+
const promise = this.page.waitForResponse(r => r.url().includes('services') && r.status() === 200);
|
|
576
|
+
await test$1.expect(link).toBeVisible();
|
|
577
|
+
await link.click();
|
|
578
|
+
await Promise.all([promise]);
|
|
579
|
+
} else {
|
|
580
|
+
await test$1.expect(link).toBeVisible();
|
|
581
|
+
await link.click();
|
|
582
|
+
await this.page.waitForLoadState('networkidle');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const CSS_VISAOCOMPLETA_TABLE = 'table.css-5cr14k';
|
|
588
|
+
const DATATESTID_PANELDATAERROR = 'data-testid Panel data error message';
|
|
589
|
+
const DATATESTID_PANELCONTENT = 'data-testid panel content';
|
|
590
|
+
const DATATESTID_PANELMENUITEMVIEW = 'data-testid Panel menu item View';
|
|
591
|
+
const DATATESTID_BACKDASHBOARD = 'data-testid Back to dashboard button';
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Page Object para interações com o Grafana (painéis RabbitMQ e Kubernetes).
|
|
595
|
+
*/
|
|
596
|
+
class GrafanaUtils {
|
|
597
|
+
/**
|
|
598
|
+
* @param {import('@playwright/test').Page} page
|
|
599
|
+
*/
|
|
600
|
+
constructor(page) {
|
|
601
|
+
this.page = page;
|
|
602
|
+
this.comunsUtils = new ComunsUtils(page);
|
|
603
|
+
this.locatorPanelDataError = page.getByTestId(DATATESTID_PANELDATAERROR);
|
|
604
|
+
this.locatorTotalUnackedMessagesPanel = page.getByTestId('data-testid Panel header Total/unacked messages').getByTestId(DATATESTID_PANELCONTENT);
|
|
605
|
+
this.locatorPublishedAckedRatesPanel = page.getByTestId('data-testid Panel header Published/Acked messages rates').getByTestId(DATATESTID_PANELCONTENT);
|
|
606
|
+
this.locatorTabelaVisaoCompletaTable = page.locator(CSS_VISAOCOMPLETA_TABLE);
|
|
607
|
+
this.locatorTotalUnackedMaxCell = this.locatorTotalUnackedMessagesPanel.getByRole('cell', {
|
|
608
|
+
name: 'Max'
|
|
609
|
+
});
|
|
610
|
+
this.locatorVisaoCompletaButton = page.getByTestId(DATATESTID_PANELMENUITEMVIEW);
|
|
611
|
+
this.locatorVoltarDashBoardButton = page.getByTestId(DATATESTID_BACKDASHBOARD);
|
|
612
|
+
this.locatorVersoesServicoPanel = page.getByTestId('data-testid Panel header Images').getByTestId(DATATESTID_PANELCONTENT);
|
|
613
|
+
this.locatorVersoesServicosVisaoCompletaPanel = page.getByTestId('data-testid Panel header Images');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Navega para a URL do Grafana e fecha o "Dock menu" se visível.
|
|
618
|
+
* @param {string} url - URL do dashboard Grafana
|
|
619
|
+
*/
|
|
620
|
+
async acessaTelaGrafana(url) {
|
|
621
|
+
await this.page.goto(url);
|
|
622
|
+
await this.page.waitForLoadState('networkidle');
|
|
623
|
+
const dockMenu = this.page.getByRole('button', {
|
|
624
|
+
name: 'Dock menu'
|
|
625
|
+
});
|
|
626
|
+
if (await dockMenu.isVisible()) {
|
|
627
|
+
await dockMenu.click();
|
|
628
|
+
console.log('Dock menu fechado');
|
|
629
|
+
}
|
|
630
|
+
console.log('Grafana carregado com sucesso');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Expande um painel colapsado no Grafana, validando que o conteúdo ficou visível.
|
|
635
|
+
* @param {string} nomePainel - Nome da row/painel a expandir
|
|
636
|
+
* @param {string} painelExpandido - Nome do painel filho para validação de visibilidade
|
|
637
|
+
*/
|
|
638
|
+
async expandirPainel(nomePainel, painelExpandido) {
|
|
639
|
+
const painel = this.page.getByTestId(`data-testid dashboard-row-title-${nomePainel}`);
|
|
640
|
+
await test$1.expect(painel).toBeVisible();
|
|
641
|
+
const painelConteudo = this.page.getByTestId(`data-testid Panel header ${painelExpandido}`).getByTestId(DATATESTID_PANELCONTENT);
|
|
642
|
+
const isVisible = await painelConteudo.isVisible().catch(() => false);
|
|
643
|
+
if (!isVisible) {
|
|
644
|
+
await painel.click();
|
|
645
|
+
console.log(`Painel "${nomePainel}" expandido`);
|
|
646
|
+
const errorCount = await this.locatorPanelDataError.count();
|
|
647
|
+
if (errorCount > 0) {
|
|
648
|
+
const relevantErrors = this.locatorPanelDataError.filter({
|
|
649
|
+
hasNot: this.page.getByTestId('data-testid Panel header Restart Pods Prod')
|
|
650
|
+
});
|
|
651
|
+
if ((await relevantErrors.count()) > 0) {
|
|
652
|
+
await test$1.expect(relevantErrors.first()).toBeHidden();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
await test$1.expect(painelConteudo).toBeVisible();
|
|
656
|
+
} else {
|
|
657
|
+
console.log(`Painel "${nomePainel}" já expandido`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Valida que o dashboard RabbitMQ foi carregado com os painéis esperados.
|
|
663
|
+
*/
|
|
664
|
+
async validarGrafanaCarregado() {
|
|
665
|
+
await test$1.expect(this.locatorTotalUnackedMessagesPanel).toBeVisible();
|
|
666
|
+
await test$1.expect(this.locatorPublishedAckedRatesPanel).toBeVisible();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Clica na célula Max da tabela para ordenar pelo total de mensagens.
|
|
671
|
+
*/
|
|
672
|
+
async ordenarMensagensTotal() {
|
|
673
|
+
await test$1.expect(this.locatorTotalUnackedMaxCell).toBeVisible();
|
|
674
|
+
await this.locatorTotalUnackedMaxCell.click();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Abre a visão completa (View) de uma seção do Grafana via hover no header + clique no menu.
|
|
679
|
+
* Faz scroll automático até o painel se necessário.
|
|
680
|
+
* @param {string} nomeSecao - Ex: 'Total/unacked messages', 'Images', 'Pods memory usage'
|
|
681
|
+
*/
|
|
682
|
+
async acessarVisaoCompleta(nomeSecao) {
|
|
683
|
+
const locatorMenu = this.page.getByTestId(`data-testid Panel menu ${nomeSecao}`);
|
|
684
|
+
const locatorHeader = this.page.getByTestId(`data-testid Panel header ${nomeSecao}`).getByTestId('header-container');
|
|
685
|
+
let isVisible = false;
|
|
686
|
+
let scrollAttempts = 0;
|
|
687
|
+
while (!isVisible && scrollAttempts < 10) {
|
|
688
|
+
try {
|
|
689
|
+
await locatorHeader.waitFor({
|
|
690
|
+
state: 'visible',
|
|
691
|
+
timeout: 2000
|
|
692
|
+
});
|
|
693
|
+
isVisible = true;
|
|
694
|
+
} catch (_) {
|
|
695
|
+
const posicaoAnterior = await this.page.evaluate(() => window.scrollY);
|
|
696
|
+
await this.page.evaluate(() => window.scrollBy(0, 500));
|
|
697
|
+
await this.comunsUtils.aguardarAte(() => this.page.evaluate(anterior => window.scrollY !== anterior, posicaoAnterior), {
|
|
698
|
+
intervalo: 50,
|
|
699
|
+
timeout: 300
|
|
700
|
+
});
|
|
701
|
+
scrollAttempts++;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
await locatorHeader.hover();
|
|
705
|
+
await locatorMenu.waitFor({
|
|
706
|
+
state: 'visible'
|
|
707
|
+
});
|
|
708
|
+
await locatorMenu.scrollIntoViewIfNeeded();
|
|
709
|
+
await test$1.expect(locatorMenu).toBeVisible();
|
|
710
|
+
await locatorMenu.click();
|
|
711
|
+
await test$1.expect(this.locatorVisaoCompletaButton).toBeVisible();
|
|
712
|
+
await this.locatorVisaoCompletaButton.click();
|
|
713
|
+
await this.page.waitForLoadState('networkidle');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Volta ao dashboard principal após visão completa.
|
|
718
|
+
*/
|
|
719
|
+
async voltarParaDashboard() {
|
|
720
|
+
await test$1.expect(this.locatorVoltarDashBoardButton).toBeVisible();
|
|
721
|
+
await this.locatorVoltarDashBoardButton.click();
|
|
722
|
+
await test$1.expect(this.locatorVoltarDashBoardButton).toBeHidden();
|
|
723
|
+
await this.comunsUtils.aguardarAte(() => this.page.getByTestId(DATATESTID_PANELCONTENT).first().isVisible(), {
|
|
724
|
+
timeout: 1000
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
exports.frame = void 0;
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Fixture Playwright estendido que injeta os page objects do core no contexto `page`.
|
|
733
|
+
*
|
|
734
|
+
* Disponível em cada test via:
|
|
735
|
+
* page.comunsUtils → ComunsUtils (login, screenshots, combinarPrints, gerarUrl*)
|
|
736
|
+
* page.grafanaUtils → GrafanaUtils (rabbit, kubernetes)
|
|
737
|
+
* page.apmUtils → ApmUtils (serviços, transactions, traces)
|
|
738
|
+
*
|
|
739
|
+
* @example
|
|
740
|
+
* import { test, expect } from 'automacao-core-carga-back/playwright';
|
|
741
|
+
*
|
|
742
|
+
* test('meu teste', async ({ page }) => {
|
|
743
|
+
* await page.comunsUtils.loginApm(APM_ERPXUI);
|
|
744
|
+
* await page.apmUtils.acessaTelaApm(url);
|
|
745
|
+
* });
|
|
746
|
+
*/
|
|
747
|
+
const test = test$1.test.extend({
|
|
748
|
+
page: async ({
|
|
749
|
+
page
|
|
750
|
+
}, use) => {
|
|
751
|
+
page['comunsUtils'] = new ComunsUtils(page);
|
|
752
|
+
page['grafanaUtils'] = new GrafanaUtils(page);
|
|
753
|
+
page['apmUtils'] = new ApmUtils(page);
|
|
754
|
+
exports.frame = page.frameLocator('iframe[name="ci"]');
|
|
755
|
+
await use(page);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
Object.defineProperty(exports, 'expect', {
|
|
760
|
+
enumerable: true,
|
|
761
|
+
get: function () { return test$1.expect; }
|
|
762
|
+
});
|
|
763
|
+
exports.ApmUtils = ApmUtils;
|
|
764
|
+
exports.ComunsUtils = ComunsUtils;
|
|
765
|
+
exports.GrafanaUtils = GrafanaUtils;
|
|
766
|
+
exports.test = test;
|