protoncloud-sdk 5.2.0__tar.gz → 5.3.0__tar.gz
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.
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/PKG-INFO +49 -2
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/README.md +47 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/pyproject.toml +5 -3
- protoncloud_sdk-5.3.0/src/proton/component_runner.py +324 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_environment.py +9 -1
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_git.py +7 -24
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_parameter.py +8 -3
- protoncloud_sdk-5.3.0/tests/test_component_runner.py +324 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/tests/test_proton_environment.py +6 -3
- protoncloud_sdk-5.3.0/tests/test_proton_parameter.py +41 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/.gitignore +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/LICENSE +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/__init__.py +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_automation.py +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_client_parama.py +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_env.py +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_files_and_resources.py +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_logs.py +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/run_status.py +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/tests/test_dados_da_execucao.py +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/tests/test_proton_env.py +0 -0
- {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/tests/test_run_status.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: protoncloud-sdk
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.3.0
|
|
4
4
|
Summary: Proton Cloud SDK para Python: execução de automações, ambientes, logs, parâmetros e recursos.
|
|
5
5
|
Project-URL: Homepage, https://protoncloud.com.br
|
|
6
6
|
Author: Atomic Solutions
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
17
|
Classifier: Topic :: Software Development :: Testing
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
|
-
Requires-Dist: requests>=2.
|
|
19
|
+
Requires-Dist: requests>=2.32.4
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
|
|
22
22
|
# Proton Cloud SDK (Python)
|
|
@@ -265,6 +265,53 @@ no lugar do último, com um `DeprecationWarning` e uma linha `[proton]` no log n
|
|
|
265
265
|
primeira vez. Para separar falha de dados de falha de ambiente, registre o
|
|
266
266
|
motivo no log da execução.
|
|
267
267
|
|
|
268
|
+
## Executor de componentes
|
|
269
|
+
|
|
270
|
+
Desde a 5.3.0 a SDK traz o laço que roda os passos do dataset, que antes cada
|
|
271
|
+
projeto copiava no `tests/test_proton_script.py`. O projeto só diz, pelo nome do
|
|
272
|
+
componente cadastrado no Proton, qual função executar:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
# tests/test_proton_script.py: o único arquivo do projeto que importa a SDK
|
|
276
|
+
from proton.component_runner import run_components
|
|
277
|
+
from automacao.passos import localizar
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_run_proton_execution():
|
|
281
|
+
run_components(localizar)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`localizar` recebe o nome do componente e devolve o passo: uma função que recebe
|
|
285
|
+
os parâmetros (`dict`) e devolve as saídas (`dict` ou `None`). Também serve um
|
|
286
|
+
`dict` de nome para função. O código de negócio não importa nada do Proton e roda
|
|
287
|
+
igual fora dele.
|
|
288
|
+
|
|
289
|
+
A cada passo, o executor:
|
|
290
|
+
|
|
291
|
+
- marca o início e o fim, inclusive quando o passo falha;
|
|
292
|
+
- passa só os parâmetros daquele passo, já resolvidos pelo Proton (variável,
|
|
293
|
+
referência a saída, valor digitado no disparo, criptografado);
|
|
294
|
+
- grava as saídas devolvidas. Uma saída que não está cadastrada como parâmetro do
|
|
295
|
+
passo vira aviso no log;
|
|
296
|
+
- sobe o que apareceu na pasta `evidencias/` durante o passo: imagens no passo, o
|
|
297
|
+
resto na execução (`evidence_dir` muda a pasta; `None` desliga);
|
|
298
|
+
- manda para a execução o que o projeto registra pelo `logging`, do nível INFO
|
|
299
|
+
para cima (`forward_logs=False` desliga).
|
|
300
|
+
|
|
301
|
+
No fim, grava `PASSED` quando não sobra passo. Quando um passo falha, grava o
|
|
302
|
+
stack trace no painel de erro, grava `FAILED` e relança a exceção. Execução parada
|
|
303
|
+
não recebe status por cima.
|
|
304
|
+
|
|
305
|
+
Um projeto pode cuidar de várias plataformas (SAP, web, API, desktop), cada uma
|
|
306
|
+
cadastrada como um sistema no Proton e todas ligadas ao mesmo repositório: o executor
|
|
307
|
+
segue no mesmo processo enquanto o projeto tiver o componente do próximo passo, e as
|
|
308
|
+
sessões abertas continuam valendo. Quando o projeto não tem o componente e o passo é de
|
|
309
|
+
outro sistema, o executor devolve o controle ao runner, que chama o projeto daquele
|
|
310
|
+
sistema.
|
|
311
|
+
|
|
312
|
+
Fora de uma execução do Proton, `run_components` lança `ProtonRunError` em vez de
|
|
313
|
+
chamar o servidor.
|
|
314
|
+
|
|
268
315
|
## Migrando de uma cópia local da pasta `proton/`
|
|
269
316
|
|
|
270
317
|
Projetos que carregam esta biblioteca como uma pasta `proton/` copiada migram em
|
|
@@ -244,6 +244,53 @@ no lugar do último, com um `DeprecationWarning` e uma linha `[proton]` no log n
|
|
|
244
244
|
primeira vez. Para separar falha de dados de falha de ambiente, registre o
|
|
245
245
|
motivo no log da execução.
|
|
246
246
|
|
|
247
|
+
## Executor de componentes
|
|
248
|
+
|
|
249
|
+
Desde a 5.3.0 a SDK traz o laço que roda os passos do dataset, que antes cada
|
|
250
|
+
projeto copiava no `tests/test_proton_script.py`. O projeto só diz, pelo nome do
|
|
251
|
+
componente cadastrado no Proton, qual função executar:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
# tests/test_proton_script.py: o único arquivo do projeto que importa a SDK
|
|
255
|
+
from proton.component_runner import run_components
|
|
256
|
+
from automacao.passos import localizar
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_run_proton_execution():
|
|
260
|
+
run_components(localizar)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`localizar` recebe o nome do componente e devolve o passo: uma função que recebe
|
|
264
|
+
os parâmetros (`dict`) e devolve as saídas (`dict` ou `None`). Também serve um
|
|
265
|
+
`dict` de nome para função. O código de negócio não importa nada do Proton e roda
|
|
266
|
+
igual fora dele.
|
|
267
|
+
|
|
268
|
+
A cada passo, o executor:
|
|
269
|
+
|
|
270
|
+
- marca o início e o fim, inclusive quando o passo falha;
|
|
271
|
+
- passa só os parâmetros daquele passo, já resolvidos pelo Proton (variável,
|
|
272
|
+
referência a saída, valor digitado no disparo, criptografado);
|
|
273
|
+
- grava as saídas devolvidas. Uma saída que não está cadastrada como parâmetro do
|
|
274
|
+
passo vira aviso no log;
|
|
275
|
+
- sobe o que apareceu na pasta `evidencias/` durante o passo: imagens no passo, o
|
|
276
|
+
resto na execução (`evidence_dir` muda a pasta; `None` desliga);
|
|
277
|
+
- manda para a execução o que o projeto registra pelo `logging`, do nível INFO
|
|
278
|
+
para cima (`forward_logs=False` desliga).
|
|
279
|
+
|
|
280
|
+
No fim, grava `PASSED` quando não sobra passo. Quando um passo falha, grava o
|
|
281
|
+
stack trace no painel de erro, grava `FAILED` e relança a exceção. Execução parada
|
|
282
|
+
não recebe status por cima.
|
|
283
|
+
|
|
284
|
+
Um projeto pode cuidar de várias plataformas (SAP, web, API, desktop), cada uma
|
|
285
|
+
cadastrada como um sistema no Proton e todas ligadas ao mesmo repositório: o executor
|
|
286
|
+
segue no mesmo processo enquanto o projeto tiver o componente do próximo passo, e as
|
|
287
|
+
sessões abertas continuam valendo. Quando o projeto não tem o componente e o passo é de
|
|
288
|
+
outro sistema, o executor devolve o controle ao runner, que chama o projeto daquele
|
|
289
|
+
sistema.
|
|
290
|
+
|
|
291
|
+
Fora de uma execução do Proton, `run_components` lança `ProtonRunError` em vez de
|
|
292
|
+
chamar o servidor.
|
|
293
|
+
|
|
247
294
|
## Migrando de uma cópia local da pasta `proton/`
|
|
248
295
|
|
|
249
296
|
Projetos que carregam esta biblioteca como uma pasta `proton/` copiada migram em
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "protoncloud-sdk"
|
|
7
|
-
version = "5.
|
|
7
|
+
version = "5.3.0"
|
|
8
8
|
description = "Proton Cloud SDK para Python: execução de automações, ambientes, logs, parâmetros e recursos."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -21,7 +21,9 @@ classifiers = [
|
|
|
21
21
|
"Programming Language :: Python :: 3.13",
|
|
22
22
|
"Topic :: Software Development :: Testing",
|
|
23
23
|
]
|
|
24
|
-
|
|
24
|
+
# Piso, e não versão fixa: biblioteca não prende quem a usa. A 2.32.4 é a primeira sem a
|
|
25
|
+
# falha do .netrc (CVE-2024-47081), que podia mandar credencial ao host errado.
|
|
26
|
+
dependencies = ["requests>=2.32.4"]
|
|
25
27
|
|
|
26
28
|
# Sem link para o repositorio: ele e privado, e o link publicado no PyPI seria
|
|
27
29
|
# um 404 para qualquer pessoa de fora.
|
|
@@ -43,7 +45,7 @@ packages = ["src/proton"]
|
|
|
43
45
|
include = ["src/proton", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
44
46
|
|
|
45
47
|
[dependency-groups]
|
|
46
|
-
dev = ["pytest>=
|
|
48
|
+
dev = ["pytest>=9.1.1"]
|
|
47
49
|
|
|
48
50
|
[tool.pytest.ini_options]
|
|
49
51
|
testpaths = ["tests"]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Executor de componentes: o laço que roda os passos do dataset numa execução do Proton.
|
|
3
|
+
|
|
4
|
+
O projeto de automação entrega uma função que, pelo nome do componente cadastrado no
|
|
5
|
+
Proton, devolve o passo a executar: uma função que recebe os parâmetros (`dict`) e
|
|
6
|
+
devolve as saídas (`dict` ou `None`). O executor cuida do resto, igual em todo projeto:
|
|
7
|
+
|
|
8
|
+
- marca o início e o fim de cada passo, inclusive quando ele falha;
|
|
9
|
+
- passa ao passo só os parâmetros dele, já resolvidos pelo Proton;
|
|
10
|
+
- grava as saídas que o passo devolve;
|
|
11
|
+
- sobe as evidências que o passo deixou na pasta de evidências;
|
|
12
|
+
- manda para a execução o que o projeto registra pelo `logging`;
|
|
13
|
+
- grava o erro e o status uma vez só.
|
|
14
|
+
|
|
15
|
+
Com isso o código de negócio não importa nada do Proton e roda igual fora dele. O
|
|
16
|
+
único arquivo do projeto que conhece a SDK é o ponto de entrada do runner
|
|
17
|
+
(`tests/test_proton_script.py`):
|
|
18
|
+
|
|
19
|
+
from proton.component_runner import run_components
|
|
20
|
+
from automacao.passos import localizar
|
|
21
|
+
|
|
22
|
+
def test_run_proton_execution():
|
|
23
|
+
run_components(localizar)
|
|
24
|
+
|
|
25
|
+
Um projeto pode cuidar de várias plataformas (SAP, web, API, desktop), cada uma cadastrada
|
|
26
|
+
como um sistema no Proton e todas ligadas ao mesmo repositório. O executor segue no mesmo
|
|
27
|
+
processo enquanto o projeto tiver o componente do próximo passo, e as sessões abertas
|
|
28
|
+
(navegador, SAP, aplicativo) continuam valendo. Quando o projeto não tem o componente e o
|
|
29
|
+
passo é de outro sistema, ele devolve o controle ao runner sem gravar status, e o runner
|
|
30
|
+
chama o projeto daquele sistema.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import threading
|
|
35
|
+
import traceback
|
|
36
|
+
from collections.abc import Mapping
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Callable, Optional, Union
|
|
39
|
+
|
|
40
|
+
from proton import proton_automation, proton_files_and_resources, proton_logs, proton_parameter
|
|
41
|
+
from proton.proton_env import is_proton_execution
|
|
42
|
+
from proton.run_status import RunStatus
|
|
43
|
+
|
|
44
|
+
Passo = Callable[[dict], Optional[Mapping]]
|
|
45
|
+
"""Um passo: recebe os parâmetros do componente e devolve as saídas (ou `None`)."""
|
|
46
|
+
|
|
47
|
+
Localizador = Union[Callable[[str], Optional[Passo]], Mapping]
|
|
48
|
+
"""Do nome do componente no Proton ao passo: uma função ou um `dict`."""
|
|
49
|
+
|
|
50
|
+
LIMITE_DE_PASSOS = 500
|
|
51
|
+
"""Proteção contra laço: nenhuma execução real chega perto disso."""
|
|
52
|
+
|
|
53
|
+
_IMAGENS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
|
|
54
|
+
_STATUS_DE_PARADA = {"Stopped", "Cancelled"}
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger("proton")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProtonRunError(RuntimeError):
|
|
60
|
+
"""Falha do executor, e não do passo: passo que não inicia, componente que não existe, laço."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ProtonLogHandler(logging.Handler):
|
|
64
|
+
"""Manda cada registro do `logging` para o log da execução no Proton."""
|
|
65
|
+
|
|
66
|
+
_PREFIXOS = {logging.WARNING: "Aviso: ", logging.ERROR: "Erro: ", logging.CRITICAL: "Erro: "}
|
|
67
|
+
|
|
68
|
+
def __init__(self, level=logging.INFO):
|
|
69
|
+
super().__init__(level)
|
|
70
|
+
self._enviando = threading.local()
|
|
71
|
+
|
|
72
|
+
def emit(self, record):
|
|
73
|
+
# O envio é uma requisição HTTP, e a biblioteca de HTTP também registra pelo
|
|
74
|
+
# `logging`: sem esta trava, um log em DEBUG geraria outro, sem fim.
|
|
75
|
+
if getattr(self._enviando, "ativo", False):
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
self._enviando.ativo = True
|
|
79
|
+
try:
|
|
80
|
+
proton_logs.set_log(self._PREFIXOS.get(record.levelno, "") + self.format(record))
|
|
81
|
+
except Exception:
|
|
82
|
+
self.handleError(record)
|
|
83
|
+
finally:
|
|
84
|
+
self._enviando.ativo = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_components(resolve: Localizador, *, evidence_dir: Optional[str] = "evidencias",
|
|
88
|
+
forward_logs: bool = True, max_steps: int = LIMITE_DE_PASSOS) -> int:
|
|
89
|
+
"""
|
|
90
|
+
Roda, na ordem do dataset, os passos da execução corrente.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
resolve: função que recebe o nome do componente e devolve o passo, ou um `dict`
|
|
94
|
+
de nome para passo. O passo recebe os parâmetros e devolve as saídas.
|
|
95
|
+
evidence_dir: pasta onde o projeto grava prints e arquivos. O que aparecer nela
|
|
96
|
+
durante um passo sobe para o Proton: imagens no passo, o resto na execução.
|
|
97
|
+
`None` desliga o envio.
|
|
98
|
+
forward_logs: manda para a execução o que o projeto registra pelo `logging`
|
|
99
|
+
(nível INFO para cima) enquanto os passos rodam.
|
|
100
|
+
max_steps: limite de passos, contra laço infinito.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
int: quantos passos rodaram.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ProtonRunError: fora de uma execução do Proton, ou quando o executor não consegue
|
|
107
|
+
seguir (passo que não inicia, componente que não existe, laço).
|
|
108
|
+
Exception: a exceção do passo que falhou, depois de gravada no Proton.
|
|
109
|
+
"""
|
|
110
|
+
if not is_proton_execution():
|
|
111
|
+
raise ProtonRunError(
|
|
112
|
+
"run_components só roda numa execução do Proton: falta idDatasetRun no ambiente. "
|
|
113
|
+
"Fora do Proton, rode os cenários do projeto direto."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
localizar = _localizador(resolve)
|
|
117
|
+
handler = _ligar_log() if forward_logs else None
|
|
118
|
+
executados = 0
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
sistema = proton_automation.get_current_component_system()
|
|
122
|
+
|
|
123
|
+
if not sistema:
|
|
124
|
+
if _foi_parada():
|
|
125
|
+
return 0
|
|
126
|
+
raise ProtonRunError("A execução não tem passo pendente: nada para rodar.")
|
|
127
|
+
|
|
128
|
+
while True:
|
|
129
|
+
nome = proton_automation.get_current_component_name()
|
|
130
|
+
|
|
131
|
+
if not nome:
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
passo, erro_ao_localizar = _procurar(localizar, nome)
|
|
135
|
+
|
|
136
|
+
# O que decide se o passo roda aqui é o projeto ter o componente (o nome é único
|
|
137
|
+
# na organização), e não o sistema: um projeto pode cuidar de várias plataformas.
|
|
138
|
+
if passo is None and erro_ao_localizar is None:
|
|
139
|
+
proximo_sistema = proton_automation.get_current_component_system()
|
|
140
|
+
|
|
141
|
+
if proximo_sistema != sistema:
|
|
142
|
+
logger.info('O componente "%s" (sistema "%s") não é deste projeto: o runner segue com o projeto dele.',
|
|
143
|
+
nome, proximo_sistema)
|
|
144
|
+
return executados
|
|
145
|
+
|
|
146
|
+
if executados >= max_steps:
|
|
147
|
+
raise ProtonRunError(f"Mais de {max_steps} passos na mesma execução: parei para não entrar em laço.")
|
|
148
|
+
|
|
149
|
+
_executar_passo(nome, passo, erro_ao_localizar, evidence_dir)
|
|
150
|
+
executados += 1
|
|
151
|
+
|
|
152
|
+
if _foi_parada():
|
|
153
|
+
return executados
|
|
154
|
+
|
|
155
|
+
proton_automation.update_run_status(RunStatus.PASSED)
|
|
156
|
+
return executados
|
|
157
|
+
|
|
158
|
+
except Exception as erro:
|
|
159
|
+
_registrar_falha(erro)
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
finally:
|
|
163
|
+
if handler is not None:
|
|
164
|
+
_desligar_log(handler)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _localizador(resolve):
|
|
168
|
+
if isinstance(resolve, Mapping):
|
|
169
|
+
return resolve.get
|
|
170
|
+
|
|
171
|
+
if callable(resolve):
|
|
172
|
+
return resolve
|
|
173
|
+
|
|
174
|
+
raise TypeError("resolve deve ser uma função (nome -> passo) ou um dict de nome para passo.")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _procurar(localizar, nome):
|
|
178
|
+
"""O passo do componente, ou o erro de quem o procurou (um import quebrado, por exemplo)."""
|
|
179
|
+
try:
|
|
180
|
+
return localizar(nome), None
|
|
181
|
+
except Exception as erro:
|
|
182
|
+
return None, erro
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _executar_passo(nome, passo, erro_ao_localizar, evidence_dir):
|
|
186
|
+
progresso = proton_automation.start_component()
|
|
187
|
+
|
|
188
|
+
# Sem o início marcado, o Proton continuaria apontando para este mesmo passo, e o
|
|
189
|
+
# laço o rodaria de novo para sempre.
|
|
190
|
+
if not progresso:
|
|
191
|
+
raise ProtonRunError(f'O Proton não iniciou o passo "{nome}".')
|
|
192
|
+
|
|
193
|
+
antes = _arquivos(evidence_dir)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
logger.info("Passo %s: %s", progresso.get("order", "?"), nome)
|
|
197
|
+
|
|
198
|
+
# O erro de quem procurou o componente só sobe agora, com o passo marcado, para a
|
|
199
|
+
# linha do tempo da execução mostrar em qual passo foi.
|
|
200
|
+
if erro_ao_localizar is not None:
|
|
201
|
+
raise erro_ao_localizar
|
|
202
|
+
|
|
203
|
+
if passo is None:
|
|
204
|
+
raise ProtonRunError(f'O componente "{nome}" não existe no projeto.')
|
|
205
|
+
|
|
206
|
+
saidas = passo(_parametros_do_passo(progresso, nome))
|
|
207
|
+
_gravar_saidas(nome, saidas)
|
|
208
|
+
|
|
209
|
+
finally:
|
|
210
|
+
try:
|
|
211
|
+
_enviar_evidencias(evidence_dir, antes)
|
|
212
|
+
except Exception:
|
|
213
|
+
logger.exception("Falha ao enviar as evidências do passo %s", nome)
|
|
214
|
+
|
|
215
|
+
proton_automation.end_component()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parametros_do_passo(progresso, nome):
|
|
219
|
+
"""
|
|
220
|
+
Só os parâmetros do passo que está rodando, com os valores já resolvidos pelo Proton
|
|
221
|
+
(variável, referência a saída, valor digitado no disparo, criptografado).
|
|
222
|
+
|
|
223
|
+
A rota devolve todos os passos do dataset; o passo corrente é o de mesma ordem que o
|
|
224
|
+
`start-component` devolveu. Pela ordem, e não pelo nome, porque o mesmo componente
|
|
225
|
+
pode aparecer duas vezes no dataset com parâmetros diferentes.
|
|
226
|
+
"""
|
|
227
|
+
componentes = proton_parameter.get_proton_all_component_parameters()
|
|
228
|
+
|
|
229
|
+
if not isinstance(componentes, list):
|
|
230
|
+
return {}
|
|
231
|
+
|
|
232
|
+
ordem = progresso.get("order")
|
|
233
|
+
candidatos = [c for c in componentes if c.get("order") == ordem]
|
|
234
|
+
escolhido = next((c for c in candidatos if c.get("component") == nome), None)
|
|
235
|
+
|
|
236
|
+
if escolhido is None:
|
|
237
|
+
escolhido = next((c for c in componentes if c.get("component") == nome), None)
|
|
238
|
+
|
|
239
|
+
if escolhido is None:
|
|
240
|
+
return {}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
p["parameter"]: "" if p.get("value") is None else str(p["value"])
|
|
244
|
+
for p in escolhido.get("params") or []
|
|
245
|
+
if p.get("parameter")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _gravar_saidas(nome, saidas):
|
|
250
|
+
if saidas is None:
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
if not isinstance(saidas, Mapping):
|
|
254
|
+
raise ProtonRunError(
|
|
255
|
+
f'O componente "{nome}" devolveu {type(saidas).__name__}: o esperado é um dict com as saídas.'
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
for parametro, valor in saidas.items():
|
|
259
|
+
status = proton_parameter.set_proton_value(parametro, "" if valor is None else valor)
|
|
260
|
+
|
|
261
|
+
if status == 404:
|
|
262
|
+
logger.warning('A saída "%s" do componente "%s" não está no passo do dataset. '
|
|
263
|
+
"Cadastre-a como parâmetro do passo para o Proton guardar o valor.", parametro, nome)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _arquivos(evidence_dir):
|
|
267
|
+
if not evidence_dir:
|
|
268
|
+
return {}
|
|
269
|
+
|
|
270
|
+
pasta = Path(evidence_dir)
|
|
271
|
+
|
|
272
|
+
if not pasta.is_dir():
|
|
273
|
+
return {}
|
|
274
|
+
|
|
275
|
+
return {arquivo: arquivo.stat().st_mtime_ns for arquivo in pasta.rglob("*") if arquivo.is_file()}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _enviar_evidencias(evidence_dir, antes):
|
|
279
|
+
"""Sobe o que apareceu (ou mudou) na pasta de evidências durante o passo."""
|
|
280
|
+
for arquivo, modificado in sorted(_arquivos(evidence_dir).items(), key=lambda item: item[1]):
|
|
281
|
+
if antes.get(arquivo) == modificado:
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
if arquivo.suffix.lower() in _IMAGENS:
|
|
285
|
+
proton_files_and_resources.upload_image(str(arquivo))
|
|
286
|
+
else:
|
|
287
|
+
proton_files_and_resources.upload_file_resource(str(arquivo))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _foi_parada():
|
|
291
|
+
"""Execução parada ou cancelada por alguém: o executor não grava status por cima."""
|
|
292
|
+
return proton_automation.get_dataset_run_status() in _STATUS_DE_PARADA
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _registrar_falha(erro):
|
|
296
|
+
try:
|
|
297
|
+
if _foi_parada():
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
proton_logs.set_error_log("".join(traceback.format_exception(type(erro), erro, erro.__traceback__)))
|
|
301
|
+
proton_automation.update_run_status(RunStatus.FAILED)
|
|
302
|
+
except Exception:
|
|
303
|
+
# A falha original é a que importa: um erro ao registrá-la não pode escondê-la.
|
|
304
|
+
logger.exception("Falha ao registrar o erro da execução no Proton")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _ligar_log():
|
|
308
|
+
handler = ProtonLogHandler()
|
|
309
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
310
|
+
raiz = logging.getLogger()
|
|
311
|
+
handler._nivel_anterior = raiz.level
|
|
312
|
+
|
|
313
|
+
# O padrão do Python é WARNING, que esconderia o logging.info dos passos.
|
|
314
|
+
if raiz.level > logging.INFO:
|
|
315
|
+
raiz.setLevel(logging.INFO)
|
|
316
|
+
|
|
317
|
+
raiz.addHandler(handler)
|
|
318
|
+
return handler
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _desligar_log(handler):
|
|
322
|
+
raiz = logging.getLogger()
|
|
323
|
+
raiz.removeHandler(handler)
|
|
324
|
+
raiz.setLevel(handler._nivel_anterior)
|
|
@@ -21,6 +21,7 @@ log da execução, e nunca trazem valores.
|
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
23
|
import re
|
|
24
|
+
from urllib.parse import quote, quote_plus
|
|
24
25
|
import threading
|
|
25
26
|
|
|
26
27
|
import requests
|
|
@@ -86,8 +87,15 @@ class _Carga:
|
|
|
86
87
|
self.error = error
|
|
87
88
|
|
|
88
89
|
# Do mais longo para o mais curto: um valor que contém outro sai inteiro.
|
|
90
|
+
# Também as formas codificadas para URL: um valor que vai na query (a saída gravada
|
|
91
|
+
# pelo set_proton_value, por exemplo) aparece assim na URL e no eco do servidor.
|
|
89
92
|
valores = sorted(
|
|
90
|
-
{
|
|
93
|
+
{
|
|
94
|
+
variante
|
|
95
|
+
for valor in self.variables.values()
|
|
96
|
+
if len(valor) >= TAMANHO_MINIMO_PARA_MASCARAR
|
|
97
|
+
for variante in (valor, quote_plus(valor), quote(valor, safe=""))
|
|
98
|
+
},
|
|
91
99
|
key=len,
|
|
92
100
|
reverse=True,
|
|
93
101
|
)
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Consulta dos repositórios Git cadastrados no Proton.
|
|
3
|
+
|
|
4
|
+
O clone de repositório saiu da SDK na 5.2.1: baixar o código é papel do runner, e o
|
|
5
|
+
clone levava o token do GitHub na linha de comando.
|
|
6
|
+
"""
|
|
7
|
+
|
|
1
8
|
import os
|
|
2
|
-
import subprocess
|
|
3
|
-
import threading
|
|
4
9
|
|
|
5
10
|
import requests
|
|
6
11
|
from proton.proton_client_parama import ProtonClientParam
|
|
7
12
|
from proton.proton_env import get_api_url, get_auth_header
|
|
8
|
-
from proton.proton_logs import set_log
|
|
9
13
|
|
|
10
14
|
|
|
11
15
|
def get_remote_repository(system_type):
|
|
@@ -62,27 +66,6 @@ def get_remote_header_hash(remote_authorization, repository, branch):
|
|
|
62
66
|
else:
|
|
63
67
|
return ""
|
|
64
68
|
|
|
65
|
-
def clone_repository(owner_repo, branch, commit_hash, authorization):
|
|
66
|
-
"""Clona um repositório Git."""
|
|
67
|
-
repo_folder = os.path.join(ProtonClientParam._repository_folder(), owner_repo, branch, commit_hash)
|
|
68
|
-
os.makedirs(repo_folder, exist_ok=True)
|
|
69
|
-
|
|
70
|
-
def run_git_clone(repo_folder, branch, authorization, owner_repo):
|
|
71
|
-
try:
|
|
72
|
-
url = f"https://{authorization}@github.com/{owner_repo}.git"
|
|
73
|
-
subprocess.run(
|
|
74
|
-
["git", "clone", "--branch", branch, url],
|
|
75
|
-
cwd=repo_folder,
|
|
76
|
-
check=True, # Levanta exceção se o git retornar código de erro
|
|
77
|
-
capture_output=True, # Captura o output
|
|
78
|
-
text=True
|
|
79
|
-
)
|
|
80
|
-
except subprocess.CalledProcessError as e:
|
|
81
|
-
set_log(f"Erro ao clonar: {e.output}")
|
|
82
|
-
|
|
83
|
-
thread = threading.Thread(target=run_git_clone, args=(repo_folder, branch, authorization, owner_repo))
|
|
84
|
-
thread.start()
|
|
85
|
-
|
|
86
69
|
def get_local_repository_path(owner_repo, repository, branch, commit_hash):
|
|
87
70
|
"""Retorna o caminho para um repositório local."""
|
|
88
71
|
repo_folder = os.path.join(ProtonClientParam._repository_folder(), owner_repo, branch, commit_hash, repository)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from urllib.parse import quote_plus
|
|
2
|
+
|
|
1
3
|
import requests
|
|
2
4
|
from proton.proton_env import get_auth_header, get_host, get_id_dataset_run, is_proton_execution
|
|
3
5
|
from proton.proton_environment import mask_environment_values
|
|
@@ -16,11 +18,14 @@ def set_proton_value(param, value):
|
|
|
16
18
|
value (str ou int): O valor a ser inserido no parâmetro de output.
|
|
17
19
|
"""
|
|
18
20
|
if is_proton_execution():
|
|
21
|
+
# O backend decodifica o valor de novo depois de ler a query (troca "+" por espaço
|
|
22
|
+
# e aplica decodeURIComponent), como a SDK Java espera: ela codifica o valor antes
|
|
23
|
+
# de montar a URL. Mandando o valor cru, um "+" virava espaço e um "%" derrubava a
|
|
24
|
+
# gravação com HTTP 500.
|
|
19
25
|
params = {
|
|
20
|
-
"parameter": param
|
|
26
|
+
"parameter": param,
|
|
27
|
+
"value": quote_plus(str(value)),
|
|
21
28
|
}
|
|
22
|
-
|
|
23
|
-
params["value"] = str(value)
|
|
24
29
|
|
|
25
30
|
# Proton 5: a rota e "/set-value"; "/setValue" (Proton 4) responde 404 e o valor nunca era gravado.
|
|
26
31
|
response = requests.put(f"{_get_dataset_run_url()}/set-value", params=params, headers=get_auth_header())
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Executor de componentes: o laço que todo projeto copiava (TestProtonScript) agora vive na
|
|
3
|
+
SDK. Os testes simulam o Proton com os passos de uma execução e conferem o contrato:
|
|
4
|
+
início e fim de cada passo, parâmetros só do passo, saídas, evidências, log, erro e status.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from proton import component_runner, proton_automation, proton_files_and_resources, proton_logs, proton_parameter
|
|
12
|
+
from proton.component_runner import ProtonRunError, run_components
|
|
13
|
+
from proton.run_status import RunStatus
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProtonFalso:
|
|
17
|
+
"""A execução no Proton: passos com sistema, ordem e parâmetros, e o que a SDK gravou."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, passos):
|
|
20
|
+
self.passos = passos
|
|
21
|
+
self.atual = 0
|
|
22
|
+
self.aberto = None
|
|
23
|
+
self.status = "Running"
|
|
24
|
+
self.chamadas = []
|
|
25
|
+
self.saidas = []
|
|
26
|
+
self.logs = []
|
|
27
|
+
self.erros = []
|
|
28
|
+
self.imagens = []
|
|
29
|
+
self.arquivos = []
|
|
30
|
+
self.saidas_fora_do_passo = set()
|
|
31
|
+
self.inicio_falha = False
|
|
32
|
+
|
|
33
|
+
def _pendente(self):
|
|
34
|
+
return self.passos[self.atual] if self.atual < len(self.passos) else None
|
|
35
|
+
|
|
36
|
+
def nome(self):
|
|
37
|
+
passo = self._pendente()
|
|
38
|
+
return passo["nome"] if passo else ""
|
|
39
|
+
|
|
40
|
+
def sistema(self):
|
|
41
|
+
passo = self._pendente()
|
|
42
|
+
return passo.get("sistema", "Web") if passo else ""
|
|
43
|
+
|
|
44
|
+
def iniciar(self):
|
|
45
|
+
self.chamadas.append("start")
|
|
46
|
+
passo = self._pendente()
|
|
47
|
+
|
|
48
|
+
if self.inicio_falha or passo is None:
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
self.aberto = passo
|
|
52
|
+
self.atual += 1
|
|
53
|
+
return {"id": 100 + self.atual, "order": passo["ordem"], "component": passo["nome"]}
|
|
54
|
+
|
|
55
|
+
def finalizar(self):
|
|
56
|
+
self.chamadas.append("end")
|
|
57
|
+
self.aberto = None
|
|
58
|
+
return 2
|
|
59
|
+
|
|
60
|
+
def parametros(self):
|
|
61
|
+
return [
|
|
62
|
+
{"component": p["nome"], "order": p["ordem"],
|
|
63
|
+
"params": [{"parameter": k, "value": v} for k, v in p.get("parametros", {}).items()]}
|
|
64
|
+
for p in self.passos
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
def gravar_saida(self, parametro, valor):
|
|
68
|
+
if parametro in self.saidas_fora_do_passo:
|
|
69
|
+
return 404
|
|
70
|
+
|
|
71
|
+
self.saidas.append((self.aberto["nome"], parametro, valor))
|
|
72
|
+
return 200
|
|
73
|
+
|
|
74
|
+
def atualizar_status(self, status):
|
|
75
|
+
self.status = status.value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def proton(monkeypatch):
|
|
80
|
+
monkeypatch.setenv("idDatasetRun", "42")
|
|
81
|
+
|
|
82
|
+
def montar(passos):
|
|
83
|
+
falso = ProtonFalso(passos)
|
|
84
|
+
monkeypatch.setattr(proton_automation, "get_current_component_name", falso.nome)
|
|
85
|
+
monkeypatch.setattr(proton_automation, "get_current_component_system", falso.sistema)
|
|
86
|
+
monkeypatch.setattr(proton_automation, "start_component", falso.iniciar)
|
|
87
|
+
monkeypatch.setattr(proton_automation, "end_component", falso.finalizar)
|
|
88
|
+
monkeypatch.setattr(proton_automation, "update_run_status", falso.atualizar_status)
|
|
89
|
+
monkeypatch.setattr(proton_automation, "get_dataset_run_status", lambda *a: falso.status)
|
|
90
|
+
monkeypatch.setattr(proton_parameter, "get_proton_all_component_parameters", falso.parametros)
|
|
91
|
+
monkeypatch.setattr(proton_parameter, "set_proton_value", falso.gravar_saida)
|
|
92
|
+
monkeypatch.setattr(proton_logs, "set_log", falso.logs.append)
|
|
93
|
+
monkeypatch.setattr(proton_logs, "set_error_log", falso.erros.append)
|
|
94
|
+
monkeypatch.setattr(proton_files_and_resources, "upload_image", falso.imagens.append)
|
|
95
|
+
monkeypatch.setattr(proton_files_and_resources, "upload_file_resource", falso.arquivos.append)
|
|
96
|
+
return falso
|
|
97
|
+
|
|
98
|
+
return montar
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_roda_os_passos_na_ordem_com_os_parametros_de_cada_um(proton):
|
|
102
|
+
falso = proton([
|
|
103
|
+
{"nome": "FazerLogin", "ordem": 1, "parametros": {"in_usuario": "ana"}},
|
|
104
|
+
{"nome": "AdicionarAoCarrinho", "ordem": 2, "parametros": {"in_produto": "Mochila", "out_preco": None}},
|
|
105
|
+
])
|
|
106
|
+
recebidos = []
|
|
107
|
+
|
|
108
|
+
def passo(nome, saidas=None):
|
|
109
|
+
def executar(params):
|
|
110
|
+
recebidos.append((nome, params))
|
|
111
|
+
return saidas
|
|
112
|
+
return executar
|
|
113
|
+
|
|
114
|
+
executados = run_components({
|
|
115
|
+
"FazerLogin": passo("FazerLogin"),
|
|
116
|
+
"AdicionarAoCarrinho": passo("AdicionarAoCarrinho", {"out_preco": "29.99"}),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
assert executados == 2
|
|
120
|
+
assert recebidos == [
|
|
121
|
+
("FazerLogin", {"in_usuario": "ana"}),
|
|
122
|
+
("AdicionarAoCarrinho", {"in_produto": "Mochila", "out_preco": ""}),
|
|
123
|
+
]
|
|
124
|
+
assert falso.chamadas == ["start", "end", "start", "end"]
|
|
125
|
+
assert falso.saidas == [("AdicionarAoCarrinho", "out_preco", "29.99")]
|
|
126
|
+
assert falso.status == RunStatus.PASSED.value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_o_mesmo_componente_duas_vezes_recebe_os_parametros_da_sua_posicao(proton):
|
|
130
|
+
proton([
|
|
131
|
+
{"nome": "AdicionarAoCarrinho", "ordem": 1, "parametros": {"in_produto": "Mochila"}},
|
|
132
|
+
{"nome": "AdicionarAoCarrinho", "ordem": 2, "parametros": {"in_produto": "Lanterna"}},
|
|
133
|
+
])
|
|
134
|
+
produtos = []
|
|
135
|
+
|
|
136
|
+
run_components(lambda nome: lambda params: produtos.append(params["in_produto"]))
|
|
137
|
+
|
|
138
|
+
assert produtos == ["Mochila", "Lanterna"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_passo_que_falha_fecha_o_passo_grava_o_erro_real_e_para(proton):
|
|
142
|
+
falso = proton([
|
|
143
|
+
{"nome": "FazerLogin", "ordem": 1},
|
|
144
|
+
{"nome": "FinalizarCompra", "ordem": 2},
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
def login_recusado(params):
|
|
148
|
+
raise AssertionError("login recusado")
|
|
149
|
+
|
|
150
|
+
with pytest.raises(AssertionError, match="login recusado"):
|
|
151
|
+
run_components({"FazerLogin": login_recusado, "FinalizarCompra": lambda p: None})
|
|
152
|
+
|
|
153
|
+
assert falso.chamadas == ["start", "end"]
|
|
154
|
+
assert falso.status == RunStatus.FAILED.value
|
|
155
|
+
assert len(falso.erros) == 1
|
|
156
|
+
# O stack trace é o do passo, e não o da maquinaria de chamada.
|
|
157
|
+
assert "login_recusado" in falso.erros[0]
|
|
158
|
+
assert "AssertionError: login recusado" in falso.erros[0]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_componente_que_nao_existe_no_projeto(proton):
|
|
162
|
+
falso = proton([{"nome": "Inexistente", "ordem": 1}])
|
|
163
|
+
|
|
164
|
+
with pytest.raises(ProtonRunError, match='"Inexistente" não existe'):
|
|
165
|
+
run_components({})
|
|
166
|
+
|
|
167
|
+
assert falso.chamadas == ["start", "end"]
|
|
168
|
+
assert falso.status == RunStatus.FAILED.value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_passo_que_o_proton_nao_inicia_nao_vira_laco(proton):
|
|
172
|
+
falso = proton([{"nome": "FazerLogin", "ordem": 1}])
|
|
173
|
+
falso.inicio_falha = True
|
|
174
|
+
|
|
175
|
+
with pytest.raises(ProtonRunError, match="não iniciou"):
|
|
176
|
+
run_components({"FazerLogin": lambda p: None})
|
|
177
|
+
|
|
178
|
+
assert falso.chamadas == ["start"]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_limite_de_passos(proton):
|
|
182
|
+
proton([{"nome": "Passo", "ordem": i} for i in range(1, 6)])
|
|
183
|
+
|
|
184
|
+
with pytest.raises(ProtonRunError, match="laço"):
|
|
185
|
+
run_components({"Passo": lambda p: None}, max_steps=3)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_proximo_passo_de_outro_sistema_que_o_projeto_nao_tem_devolve_ao_runner(proton):
|
|
189
|
+
falso = proton([
|
|
190
|
+
{"nome": "FazerLogin", "ordem": 1, "sistema": "Web"},
|
|
191
|
+
{"nome": "ConferirPedido", "ordem": 2, "sistema": "SAP"},
|
|
192
|
+
])
|
|
193
|
+
|
|
194
|
+
assert run_components({"FazerLogin": lambda p: None}) == 1
|
|
195
|
+
assert falso.status == "Running"
|
|
196
|
+
assert falso.chamadas == ["start", "end"]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_proximo_passo_de_outro_sistema_que_o_projeto_tem_roda_no_mesmo_processo(proton):
|
|
200
|
+
falso = proton([
|
|
201
|
+
{"nome": "CriarPedido", "ordem": 1, "sistema": "SAP"},
|
|
202
|
+
{"nome": "ConferirPedido", "ordem": 2, "sistema": "Portal"},
|
|
203
|
+
{"nome": "ConsultarPedido", "ordem": 3, "sistema": "API"},
|
|
204
|
+
])
|
|
205
|
+
rodados = []
|
|
206
|
+
|
|
207
|
+
def passo(nome):
|
|
208
|
+
return lambda params: rodados.append(nome)
|
|
209
|
+
|
|
210
|
+
assert run_components(passo) == 3
|
|
211
|
+
assert rodados == ["CriarPedido", "ConferirPedido", "ConsultarPedido"]
|
|
212
|
+
assert falso.status == RunStatus.PASSED.value
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_componente_que_falta_no_mesmo_sistema_falha_em_vez_de_devolver_ao_runner(proton):
|
|
216
|
+
falso = proton([
|
|
217
|
+
{"nome": "FazerLogin", "ordem": 1, "sistema": "Web"},
|
|
218
|
+
{"nome": "Esquecido", "ordem": 2, "sistema": "Web"},
|
|
219
|
+
])
|
|
220
|
+
|
|
221
|
+
with pytest.raises(ProtonRunError, match='"Esquecido" não existe'):
|
|
222
|
+
run_components({"FazerLogin": lambda p: None})
|
|
223
|
+
|
|
224
|
+
assert falso.chamadas == ["start", "end", "start", "end"]
|
|
225
|
+
assert falso.status == RunStatus.FAILED.value
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_erro_ao_procurar_o_componente_marca_o_passo_e_falha(proton):
|
|
229
|
+
falso = proton([{"nome": "FazerLogin", "ordem": 1}])
|
|
230
|
+
|
|
231
|
+
def localizar(nome):
|
|
232
|
+
raise ImportError("falta a biblioteca do SAP")
|
|
233
|
+
|
|
234
|
+
with pytest.raises(ImportError, match="biblioteca do SAP"):
|
|
235
|
+
run_components(localizar)
|
|
236
|
+
|
|
237
|
+
assert falso.chamadas == ["start", "end"]
|
|
238
|
+
assert falso.status == RunStatus.FAILED.value
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_execucao_parada_nao_recebe_passed(proton):
|
|
242
|
+
falso = proton([{"nome": "FazerLogin", "ordem": 1}])
|
|
243
|
+
|
|
244
|
+
def parar(params):
|
|
245
|
+
falso.status = "Stopped"
|
|
246
|
+
|
|
247
|
+
run_components({"FazerLogin": parar})
|
|
248
|
+
|
|
249
|
+
assert falso.status == "Stopped"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_execucao_sem_passo_pendente_nao_passa(proton):
|
|
253
|
+
falso = proton([])
|
|
254
|
+
|
|
255
|
+
with pytest.raises(ProtonRunError, match="não tem passo pendente"):
|
|
256
|
+
run_components({})
|
|
257
|
+
|
|
258
|
+
assert falso.status == RunStatus.FAILED.value
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_saida_fora_do_passo_avisa_sem_falhar(proton, caplog):
|
|
262
|
+
falso = proton([{"nome": "FinalizarCompra", "ordem": 1}])
|
|
263
|
+
falso.saidas_fora_do_passo = {"out_total"}
|
|
264
|
+
|
|
265
|
+
run_components({"FinalizarCompra": lambda p: {"out_total": "32.39"}})
|
|
266
|
+
|
|
267
|
+
assert falso.status == RunStatus.PASSED.value
|
|
268
|
+
assert any("out_total" in log and log.startswith("Aviso: ") for log in falso.logs)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_saida_que_nao_e_dict_falha(proton):
|
|
272
|
+
proton([{"nome": "FinalizarCompra", "ordem": 1}])
|
|
273
|
+
|
|
274
|
+
with pytest.raises(ProtonRunError, match="esperado é um dict"):
|
|
275
|
+
run_components({"FinalizarCompra": lambda p: "32.39"})
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_evidencias_novas_do_passo_sobem_e_as_antigas_nao(proton, tmp_path):
|
|
279
|
+
pasta = tmp_path / "evidencias"
|
|
280
|
+
pasta.mkdir()
|
|
281
|
+
(pasta / "antiga.png").write_bytes(b"x")
|
|
282
|
+
falso = proton([{"nome": "FinalizarCompra", "ordem": 1}])
|
|
283
|
+
|
|
284
|
+
def finalizar(params):
|
|
285
|
+
(pasta / "pedido.png").write_bytes(b"x")
|
|
286
|
+
(pasta / "pedido.html").write_text("<html/>")
|
|
287
|
+
|
|
288
|
+
run_components({"FinalizarCompra": finalizar}, evidence_dir=str(pasta))
|
|
289
|
+
|
|
290
|
+
assert falso.imagens == [str(pasta / "pedido.png")]
|
|
291
|
+
assert falso.arquivos == [str(pasta / "pedido.html")]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_evidencia_do_passo_que_falha_tambem_sobe(proton, tmp_path):
|
|
295
|
+
falso = proton([{"nome": "FinalizarCompra", "ordem": 1}])
|
|
296
|
+
|
|
297
|
+
def finalizar(params):
|
|
298
|
+
(tmp_path / "falha.png").write_bytes(b"x")
|
|
299
|
+
raise AssertionError("total errado")
|
|
300
|
+
|
|
301
|
+
with pytest.raises(AssertionError):
|
|
302
|
+
run_components({"FinalizarCompra": finalizar}, evidence_dir=str(tmp_path))
|
|
303
|
+
|
|
304
|
+
assert falso.imagens == [str(tmp_path / "falha.png")]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_log_do_projeto_vai_para_a_execucao_so_durante_os_passos(proton):
|
|
308
|
+
falso = proton([{"nome": "FazerLogin", "ordem": 1}])
|
|
309
|
+
log = logging.getLogger("automacao")
|
|
310
|
+
|
|
311
|
+
run_components({"FazerLogin": lambda p: log.info("tela de produtos aberta")})
|
|
312
|
+
log.warning("depois da execução")
|
|
313
|
+
|
|
314
|
+
assert "tela de produtos aberta" in falso.logs
|
|
315
|
+
assert "Passo 1: FazerLogin" in falso.logs
|
|
316
|
+
assert not any("depois da execução" in item for item in falso.logs)
|
|
317
|
+
assert not any(isinstance(h, component_runner.ProtonLogHandler) for h in logging.getLogger().handlers)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_fora_do_proton_avisa_em_vez_de_chamar_o_servidor(monkeypatch):
|
|
321
|
+
monkeypatch.delenv("idDatasetRun", raising=False)
|
|
322
|
+
|
|
323
|
+
with pytest.raises(ProtonRunError, match="só roda numa execução do Proton"):
|
|
324
|
+
run_components({})
|
|
@@ -12,7 +12,7 @@ import os
|
|
|
12
12
|
import threading
|
|
13
13
|
import warnings
|
|
14
14
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
-
from urllib.parse import parse_qs, urlsplit
|
|
15
|
+
from urllib.parse import parse_qs, unquote, urlsplit
|
|
16
16
|
|
|
17
17
|
import pytest
|
|
18
18
|
import requests
|
|
@@ -325,8 +325,11 @@ def test_valores_mascarados_no_log(proton, execucao, capsys, monkeypatch):
|
|
|
325
325
|
# Prints da própria lib: rota que ecoa o valor e parâmetro de saída recusado.
|
|
326
326
|
assert get_current_component_name() == ""
|
|
327
327
|
assert set_proton_value("SENHA_LIDA", senha) == 400
|
|
328
|
-
# O parâmetro de saída é gravado de propósito: o valor enviado vai aberto.
|
|
329
|
-
|
|
328
|
+
# O parâmetro de saída é gravado de propósito: o valor enviado vai aberto. O backend
|
|
329
|
+
# decodifica o valor de novo depois de ler a query (troca "+" por espaço e aplica
|
|
330
|
+
# decodeURIComponent), e é aí que ele tem de chegar inteiro.
|
|
331
|
+
enviado = proton.valores_gravados[0]["value"][0]
|
|
332
|
+
assert unquote(enviado.replace("+", " "), errors="strict") == SAP_SENHA
|
|
330
333
|
|
|
331
334
|
# Representação em texto do mapa e mensagens de erro.
|
|
332
335
|
variaveis = get_environment_variables()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Saída gravada pelo set_proton_value: o backend lê a query e decodifica o valor de novo
|
|
3
|
+
(troca "+" por espaço e aplica decodeURIComponent). O valor tem de chegar inteiro, com
|
|
4
|
+
"+", "%", acento e quebra de linha.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from proton import proton_parameter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Resposta:
|
|
15
|
+
status_code = 200
|
|
16
|
+
text = "{}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def backend(monkeypatch):
|
|
21
|
+
monkeypatch.setenv("idDatasetRun", "42")
|
|
22
|
+
monkeypatch.setenv("PROTON_HOST", "http://localhost:3333/api")
|
|
23
|
+
monkeypatch.setenv("PROTON_TOKEN", "proton_run_teste")
|
|
24
|
+
gravados = []
|
|
25
|
+
|
|
26
|
+
def put(url, params=None, headers=None):
|
|
27
|
+
# O que o requests põe na URL e o que o Adonis lê da query...
|
|
28
|
+
consulta = parse_qs(urlparse(proton_parameter.requests.Request("PUT", url, params=params).prepare().url).query)
|
|
29
|
+
valor = consulta["value"][0]
|
|
30
|
+
# ...e o que o set-value do backend faz em seguida.
|
|
31
|
+
gravados.append(unquote(valor.replace("+", " "), errors="strict"))
|
|
32
|
+
return _Resposta()
|
|
33
|
+
|
|
34
|
+
monkeypatch.setattr(proton_parameter.requests, "put", put)
|
|
35
|
+
return gravados
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("valor", ["São Paulo", "+55 11 91234-5678", "50% de desconto", "a&b=c", "linha 1\nlinha 2", "32.39"])
|
|
39
|
+
def test_valor_chega_inteiro_no_backend(backend, valor):
|
|
40
|
+
assert proton_parameter.set_proton_value("out_valor", valor) == 200
|
|
41
|
+
assert backend == [valor]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|