Atualizações de objetos a serem criados para contrinuição.Aplicativo de testes do manifesto.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,123 @@
|
||||
# Teste local de módulos
|
||||
|
||||
Esta tela ajuda a testar as queries dos módulos do `app-dono-modulos` sem precisar alterar o app principal.
|
||||
|
||||
## Como abrir
|
||||
|
||||
Execute o app de teste e acesse a URL exibida no terminal:
|
||||
|
||||
```bash
|
||||
npm run test:app
|
||||
```
|
||||
|
||||
Também é possível escolher uma porta:
|
||||
|
||||
```bash
|
||||
npm run test:app -- --port=4320
|
||||
```
|
||||
|
||||
## Campos principais
|
||||
|
||||
- **Módulo**: seleciona o módulo do manifesto, por exemplo `flash-de-perdas`.
|
||||
- **Login remoto**: URL do endpoint de login usado para gerar o bearer.
|
||||
- **Email/Senha**: credenciais enviadas ao endpoint de login.
|
||||
- **Query**: seleciona a query do módulo, por exemplo `flash_categorias`.
|
||||
- **Sistema**: seleciona a implementação local da query, como `C5_big`.
|
||||
- **Parâmetros**: mostra os parâmetros declarados na query.
|
||||
- **Manifesto remoto**: base usada para montar a chamada remota.
|
||||
- **Bearer token**: token de autenticação usado na chamada remota.
|
||||
- **Client ID**: valor enviado no header `x-client-id`.
|
||||
|
||||
## Gerar bearer
|
||||
|
||||
Antes de executar o manifesto remoto, preencha a URL de login, o email e a senha.
|
||||
|
||||
O botão **Gerar bearer** chama o endpoint de login com o JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "usuario@exemplo.com",
|
||||
"senha": "senha"
|
||||
}
|
||||
```
|
||||
|
||||
Quando o login responde com sucesso, o app usa a propriedade `token` do JSON de retorno. Como apoio,
|
||||
também procura em campos comuns como `access_token`, `accessToken`, `bearer`, `bearerToken` ou `jwt`. O valor encontrado é inserido
|
||||
automaticamente no campo **Bearer token**.
|
||||
|
||||
## Renderizar SQL
|
||||
|
||||
O botão **Renderizar SQL** executa a implementação local da query e mostra o SQL final no quadro preto.
|
||||
|
||||
Para valores usados diretamente no SQL, informe literais SQL. Exemplo:
|
||||
|
||||
```sql
|
||||
'2026-05-05'
|
||||
```
|
||||
|
||||
Para parâmetros de contexto, como `ctx_user_companies_for_module`, use uma lista SQL:
|
||||
|
||||
```sql
|
||||
1,2,3
|
||||
```
|
||||
|
||||
## Executar manifesto
|
||||
|
||||
O botão **Executar manifesto** chama a API remota usando os valores da tela.
|
||||
|
||||
A URL é montada no formato do Postman:
|
||||
|
||||
```text
|
||||
{baseUrl}/api/manifest/modules/{modulo}/queries/{query}/execute
|
||||
```
|
||||
|
||||
Com o `baseUrl` preenchido como:
|
||||
|
||||
```text
|
||||
https://app-dono.vitruvio.com.br/api
|
||||
```
|
||||
|
||||
a chamada fica:
|
||||
|
||||
```text
|
||||
https://app-dono.vitruvio.com.br/api/api/manifest/modules/{modulo}/queries/{query}/execute
|
||||
```
|
||||
|
||||
O quadro preto mostra somente o body retornado pela API, como no Postman. O status HTTP e a URL chamada aparecem abaixo dos botões.
|
||||
|
||||
## Parâmetros nulos
|
||||
|
||||
Para enviar `null` no JSON remoto, escreva:
|
||||
|
||||
```text
|
||||
null
|
||||
```
|
||||
|
||||
Exemplo para `flash-de-perdas` / `flash_categorias`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data_perda": "2026-05-05",
|
||||
"codigo_categoria_pai": null,
|
||||
"cod_empresa": null
|
||||
}
|
||||
```
|
||||
|
||||
Na tela, preencha:
|
||||
|
||||
```text
|
||||
data_perda = '2026-05-05'
|
||||
codigo_categoria_pai = null
|
||||
cod_empresa = null
|
||||
```
|
||||
|
||||
## Botões auxiliares
|
||||
|
||||
- **Copiar**: copia o conteúdo atual do quadro preto.
|
||||
- **Copiar resposta**: copia o conteúdo atual do quadro preto depois de uma chamada remota.
|
||||
- **Rodar vet**: executa `npm run vet` para validar os módulos.
|
||||
- **Recarregar**: recarrega a lista de módulos e queries.
|
||||
|
||||
## Observação
|
||||
|
||||
O teste local usa os arquivos atuais do projeto. A execução remota usa o manifesto publicado no ambiente remoto.
|
||||
@@ -0,0 +1,885 @@
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
|
||||
type ModuleDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
entrypoint: string;
|
||||
queries: Record<string, { name: string; params: string[]; display: unknown }>;
|
||||
implementations: Record<string, Record<string, (args: Record<string, unknown>) => { sql: string }>>;
|
||||
schedules?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const root = process.cwd();
|
||||
const srcDir = path.join(root, "src");
|
||||
const portArg = Number(process.argv.find((arg) => arg.startsWith("--port="))?.split("=")[1]);
|
||||
const startPort = Number.isFinite(portArg) && portArg > 0 ? portArg : 4317;
|
||||
|
||||
async function findModuleFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return findModuleFiles(fullPath);
|
||||
if (entry.isFile() && entry.name.endsWith(".module.ts")) return [fullPath];
|
||||
return [];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
const files = await findModuleFiles(srcDir);
|
||||
const modules = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const imported = await import(pathToFileURL(file).href + `?t=${Date.now()}`);
|
||||
const module = imported.default as ModuleDefinition;
|
||||
return {
|
||||
file: path.relative(root, file).replace(/\\/g, "/"),
|
||||
module,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return modules.sort((a, b) => a.module.id.localeCompare(b.module.id));
|
||||
}
|
||||
|
||||
function json(res: http.ServerResponse, status: number, data: unknown) {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function text(res: http.ServerResponse, status: number, data: string) {
|
||||
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
function html(res: http.ServerResponse, status: number, data: string) {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
function jpg(res: http.ServerResponse, status: number, data: Buffer) {
|
||||
res.writeHead(status, {
|
||||
"content-type": "image/jpeg",
|
||||
"cache-control": "public, max-age=3600",
|
||||
});
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
async function readJsonBody(req: http.IncomingMessage) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
}
|
||||
|
||||
function defaultArgs(params: string[]) {
|
||||
const args: Record<string, string> = {
|
||||
ctx_user_companies: "1,2,3",
|
||||
ctx_user_companies_for_module: "1,2,3",
|
||||
};
|
||||
for (const param of params) args[param] = `:${param}`;
|
||||
return args;
|
||||
}
|
||||
|
||||
function vetProject() {
|
||||
return new Promise<{ code: number | null; output: string }>((resolve) => {
|
||||
const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "vet"], {
|
||||
cwd: root,
|
||||
shell: false,
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => (output += chunk.toString()));
|
||||
child.stderr.on("data", (chunk) => (output += chunk.toString()));
|
||||
child.on("close", (code) => resolve({ code, output }));
|
||||
});
|
||||
}
|
||||
|
||||
function parseManifestValue(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.toLowerCase() === "null") return null;
|
||||
if (trimmed.toLowerCase() === "true") return true;
|
||||
if (trimmed.toLowerCase() === "false") return false;
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
if (
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function findBearerToken(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
|
||||
if (
|
||||
"token" in value &&
|
||||
typeof (value as Record<string, unknown>).token === "string" &&
|
||||
(value as Record<string, string>).token.trim()
|
||||
) {
|
||||
return (value as Record<string, string>).token.replace(/^Bearer\s+/i, "").trim();
|
||||
}
|
||||
|
||||
const preferredKeys = new Set([
|
||||
"access_token",
|
||||
"accessToken",
|
||||
"bearer",
|
||||
"bearerToken",
|
||||
"token",
|
||||
"jwt",
|
||||
]);
|
||||
const preferredLowerKeys = new Set([...preferredKeys].map((key) => key.toLowerCase()));
|
||||
|
||||
const queue: unknown[] = [value];
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
if (!current || typeof current !== "object") continue;
|
||||
|
||||
for (const [key, nested] of Object.entries(current)) {
|
||||
if (typeof nested === "string" && preferredKeys.has(key)) {
|
||||
return nested.replace(/^Bearer\s+/i, "").trim();
|
||||
}
|
||||
if (typeof nested === "string" && preferredLowerKeys.has(key.toLowerCase())) {
|
||||
return nested.replace(/^Bearer\s+/i, "").trim();
|
||||
}
|
||||
if (nested && typeof nested === "object") queue.push(nested);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function postLogin(loginUrl: string, payload: Record<string, string>) {
|
||||
const response = await fetch(loginUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const textBody = await response.text();
|
||||
let parsedBody: unknown = textBody;
|
||||
try {
|
||||
parsedBody = textBody ? JSON.parse(textBody) : null;
|
||||
} catch {
|
||||
parsedBody = textBody;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
bearerToken: findBearerToken(parsedBody),
|
||||
responseBody: parsedBody,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleApi(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/modules") {
|
||||
const modules = await loadModules();
|
||||
return json(
|
||||
res,
|
||||
200,
|
||||
modules.map(({ file, module }) => ({
|
||||
file,
|
||||
id: module.id,
|
||||
label: module.label,
|
||||
entrypoint: module.entrypoint,
|
||||
queries: Object.entries(module.queries).map(([key, query]) => ({
|
||||
key,
|
||||
name: query.name,
|
||||
params: query.params,
|
||||
})),
|
||||
systems: Object.keys(module.implementations || {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/assets/logo-davinti.jpg") {
|
||||
const image = await readFile(path.join(root, "scripts", "assets", "logo-davinti.jpg"));
|
||||
return jpg(res, 200, image);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/docs") {
|
||||
const markdown = await readFile(path.join(root, "scripts", "module-test-app.md"), "utf8");
|
||||
return json(res, 200, { markdown });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/render") {
|
||||
const body = await readJsonBody(req);
|
||||
const modules = await loadModules();
|
||||
const found = modules.find(({ module }) => module.id === body.moduleId);
|
||||
if (!found) return json(res, 404, { error: "Module not found" });
|
||||
|
||||
const query = found.module.queries[body.queryKey];
|
||||
const implementation = found.module.implementations?.[body.system]?.[body.queryKey];
|
||||
if (!query || !implementation) return json(res, 404, { error: "Query implementation not found" });
|
||||
|
||||
const args = { ...defaultArgs(query.params), ...(body.args || {}) };
|
||||
const result = implementation(args);
|
||||
return json(res, 200, { sql: result.sql, args });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/login") {
|
||||
const body = await readJsonBody(req);
|
||||
const loginUrl = String(body.loginUrl || "");
|
||||
const email = String(body.email || "");
|
||||
const senha = String(body.senha || "");
|
||||
|
||||
if (!loginUrl || !email || !senha) {
|
||||
return json(res, 400, { error: "loginUrl, email e senha sao obrigatorios" });
|
||||
}
|
||||
|
||||
const attempts = [
|
||||
{ email, senha },
|
||||
{ email, password: senha },
|
||||
];
|
||||
const results = [];
|
||||
|
||||
for (const payload of attempts) {
|
||||
const result = await postLogin(loginUrl, payload);
|
||||
results.push(result);
|
||||
if (result.ok && result.bearerToken) {
|
||||
return json(res, 200, {
|
||||
status: result.status,
|
||||
bearerToken: result.bearerToken,
|
||||
responseBody: result.responseBody,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lastResult = results[results.length - 1];
|
||||
const successfulWithoutToken = results.find((result) => result.ok);
|
||||
if (successfulWithoutToken) {
|
||||
return json(res, 502, {
|
||||
error: "Login realizado, mas nao encontrei token no retorno",
|
||||
status: successfulWithoutToken.status,
|
||||
responseBody: successfulWithoutToken.responseBody,
|
||||
});
|
||||
}
|
||||
|
||||
return json(res, lastResult.status, {
|
||||
error: "Login retornou erro HTTP " + lastResult.status,
|
||||
status: lastResult.status,
|
||||
responseBody: lastResult.responseBody,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/manifest-execute") {
|
||||
const body = await readJsonBody(req);
|
||||
const baseUrl = String(body.baseUrl || "").replace(/\/+$/, "");
|
||||
const moduleId = String(body.moduleId || "");
|
||||
const queryKey = String(body.queryKey || "");
|
||||
const clientId = String(body.clientId || "");
|
||||
const bearerToken = String(body.bearerToken || "");
|
||||
const params = body.params || {};
|
||||
|
||||
if (!baseUrl || !moduleId || !queryKey) {
|
||||
return json(res, 400, { error: "baseUrl, moduleId e queryKey sao obrigatorios" });
|
||||
}
|
||||
|
||||
const endpoint = `${baseUrl}/api/manifest/modules/${encodeURIComponent(moduleId)}/queries/${encodeURIComponent(queryKey)}/execute`;
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (clientId) headers["x-client-id"] = clientId;
|
||||
if (bearerToken) headers.authorization = `Bearer ${bearerToken}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
const textBody = await response.text();
|
||||
let parsedBody: unknown = textBody;
|
||||
try {
|
||||
parsedBody = textBody ? JSON.parse(textBody) : null;
|
||||
} catch {
|
||||
parsedBody = textBody;
|
||||
}
|
||||
|
||||
return json(res, 200, {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
url: endpoint,
|
||||
requestBody: params,
|
||||
responseBody: parsedBody,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/vet") {
|
||||
const result = await vetProject();
|
||||
return json(res, result.code === 0 ? 200 : 500, result);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const page = String.raw`<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>App Dono Modulos - Teste Local</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: Inter, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
--ink: #17212f;
|
||||
--muted: #637186;
|
||||
--line: #dce5ef;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f5f8fb;
|
||||
--brand-navy: #0f2b46;
|
||||
--brand-blue: #0b74b8;
|
||||
--brand-cyan: #14a8c9;
|
||||
--brand-orange: #f28c28;
|
||||
--brand-green: #22a06b;
|
||||
--code-bg: #0b1726;
|
||||
--code-border: #16314f;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(11, 116, 184, 0.09), transparent 290px),
|
||||
#eef4f8;
|
||||
color: var(--ink);
|
||||
}
|
||||
header {
|
||||
background: var(--brand-navy);
|
||||
color: white;
|
||||
border-bottom: 4px solid var(--brand-orange);
|
||||
box-shadow: 0 16px 40px rgba(15, 43, 70, 0.18);
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 170px;
|
||||
height: 50px;
|
||||
padding: 7px 9px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
box-shadow: 0 10px 24px rgba(3, 18, 33, 0.18);
|
||||
}
|
||||
.brand-logo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.brand-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand-title strong {
|
||||
font-size: 18px;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-title span,
|
||||
.environment {
|
||||
color: #c9d8e8;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.environment {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(330px, 410px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 18px;
|
||||
}
|
||||
section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 34px rgba(15, 43, 70, 0.08);
|
||||
}
|
||||
.controls {
|
||||
padding: 16px;
|
||||
align-self: start;
|
||||
}
|
||||
.output {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.panel-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.panel-heading span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.group {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
.group:first-of-type {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
color: var(--brand-navy);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.group-title::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--brand-orange);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 10px 0 6px;
|
||||
color: #435166;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
select, input, textarea, button {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
}
|
||||
select, input, textarea {
|
||||
border: 1px solid #cbd8e6;
|
||||
border-radius: 6px;
|
||||
padding: 10px 11px;
|
||||
background: white;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||
}
|
||||
select:focus, input:focus, textarea:focus {
|
||||
border-color: var(--brand-blue);
|
||||
box-shadow: 0 0 0 3px rgba(11, 116, 184, 0.16);
|
||||
}
|
||||
input::placeholder { color: #9aa8b8; }
|
||||
button {
|
||||
min-height: 42px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
background: var(--brand-blue);
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px rgba(11, 116, 184, 0.2);
|
||||
transition: transform 120ms ease, filter 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
button:hover {
|
||||
filter: brightness(1.05);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgba(11, 116, 184, 0.24);
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
button.secondary {
|
||||
background: #31465e;
|
||||
box-shadow: 0 8px 18px rgba(49, 70, 94, 0.16);
|
||||
}
|
||||
button.accent {
|
||||
background: var(--brand-orange);
|
||||
box-shadow: 0 8px 18px rgba(242, 140, 40, 0.22);
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.params {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid #e3ebf3;
|
||||
}
|
||||
.params label {
|
||||
margin-top: 0;
|
||||
}
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
margin-top: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.status {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
min-height: 38px;
|
||||
border-radius: 8px;
|
||||
background: #eef8f3;
|
||||
border: 1px solid #ccebdd;
|
||||
color: #22543d;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
pre {
|
||||
height: calc(100vh - 116px);
|
||||
min-height: 560px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
border-top: 4px solid var(--brand-cyan);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(20, 168, 201, 0.09), transparent 160px),
|
||||
var(--code-bg);
|
||||
color: #d7f7ed;
|
||||
font-family: "Cascadia Code", Consolas, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
main { grid-template-columns: 1fr; }
|
||||
pre { height: auto; min-height: 460px; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.topbar { align-items: flex-start; flex-direction: column; }
|
||||
.brand { align-items: flex-start; flex-direction: column; }
|
||||
.brand-logo { width: 184px; }
|
||||
main { padding: 12px; }
|
||||
.row { grid-template-columns: 1fr; }
|
||||
.environment { white-space: normal; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">
|
||||
<img src="/assets/logo-davinti.jpg" alt="Davinti solucoes em tecnologia" />
|
||||
</div>
|
||||
<div class="brand-title">
|
||||
<strong>App Dono Modulos</strong>
|
||||
<span>Teste local de queries e manifesto remoto</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="environment">Vitruvio manifest tools</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="controls">
|
||||
<div class="panel-heading">
|
||||
<h1>Modulo de teste</h1>
|
||||
<span>Local + remoto</span>
|
||||
</div>
|
||||
<div class="group">
|
||||
<p class="group-title">Selecao</p>
|
||||
<label>Modulo</label>
|
||||
<select id="module"></select>
|
||||
<label>Query</label>
|
||||
<select id="query"></select>
|
||||
<label>Sistema</label>
|
||||
<select id="system"></select>
|
||||
<div class="hint">Os valores sao expressoes SQL cruas. Para testar literal de data, use aspas: '2026-05-08'.</div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<p class="group-title">Autenticacao</p>
|
||||
<label>Login remoto</label>
|
||||
<input id="loginUrl" value="https://app-dono.vitruvio.com.br/api/login" />
|
||||
<div class="row">
|
||||
<input id="loginEmail" type="email" placeholder="email" autocomplete="username" />
|
||||
<input id="loginPassword" type="password" placeholder="senha" autocomplete="current-password" />
|
||||
</div>
|
||||
<button id="login" class="accent" style="margin-top: 8px;">Gerar bearer</button>
|
||||
<label>Bearer token</label>
|
||||
<input id="bearerToken" placeholder="eyJ..." />
|
||||
<label>Client ID</label>
|
||||
<input id="clientId" placeholder="019d..." />
|
||||
</div>
|
||||
<div class="group">
|
||||
<p class="group-title">Parametros</p>
|
||||
<div id="params" class="params"></div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<p class="group-title">Manifesto</p>
|
||||
<label>Manifesto remoto</label>
|
||||
<input id="baseUrl" value="https://app-dono.vitruvio.com.br/api" />
|
||||
<div class="hint" id="remoteUrl"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="render">Renderizar SQL</button>
|
||||
<button id="copy" class="secondary">Copiar</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="executeManifest">Executar manifesto</button>
|
||||
<button id="copyResponse" class="secondary">Copiar resposta</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="vet" class="secondary">Rodar vet</button>
|
||||
<button id="reload" class="secondary">Recarregar</button>
|
||||
</div>
|
||||
<div id="status" class="status"></div>
|
||||
</section>
|
||||
<section class="output">
|
||||
<pre id="sql"></pre>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
let modules = [];
|
||||
const els = {
|
||||
module: document.getElementById("module"),
|
||||
query: document.getElementById("query"),
|
||||
system: document.getElementById("system"),
|
||||
params: document.getElementById("params"),
|
||||
sql: document.getElementById("sql"),
|
||||
status: document.getElementById("status"),
|
||||
loginUrl: document.getElementById("loginUrl"),
|
||||
loginEmail: document.getElementById("loginEmail"),
|
||||
loginPassword: document.getElementById("loginPassword"),
|
||||
baseUrl: document.getElementById("baseUrl"),
|
||||
bearerToken: document.getElementById("bearerToken"),
|
||||
clientId: document.getElementById("clientId"),
|
||||
remoteUrl: document.getElementById("remoteUrl"),
|
||||
};
|
||||
|
||||
async function api(path, options) {
|
||||
const res = await fetch(path, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const detail = data.responseBody ? "\\n" + JSON.stringify(data.responseBody, null, 2) : "";
|
||||
throw new Error((data.error || data.output || "Erro") + detail);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function selectedModule() { return modules.find(m => m.id === els.module.value); }
|
||||
function selectedQuery() { return selectedModule()?.queries.find(q => q.key === els.query.value); }
|
||||
|
||||
function fillSelect(select, items, valueFn, labelFn) {
|
||||
select.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = valueFn(item);
|
||||
opt.textContent = labelFn(item);
|
||||
select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshQueries() {
|
||||
const mod = selectedModule();
|
||||
fillSelect(els.query, mod?.queries || [], q => q.key, q => q.key + " - " + q.name);
|
||||
fillSelect(els.system, mod?.systems || [], s => s, s => s);
|
||||
refreshParams();
|
||||
}
|
||||
|
||||
function refreshParams() {
|
||||
const query = selectedQuery();
|
||||
els.params.innerHTML = "";
|
||||
const params = [...(query?.params || []), "ctx_user_companies_for_module", "ctx_user_companies"];
|
||||
for (const param of params) {
|
||||
const wrap = document.createElement("div");
|
||||
const label = document.createElement("label");
|
||||
const input = document.createElement("input");
|
||||
label.textContent = param;
|
||||
input.name = param;
|
||||
input.value = param.startsWith("ctx_") ? "1,2,3" : ":" + param;
|
||||
wrap.append(label, input);
|
||||
els.params.appendChild(wrap);
|
||||
}
|
||||
refreshRemoteUrl();
|
||||
}
|
||||
|
||||
function refreshRemoteUrl() {
|
||||
const baseUrl = String(els.baseUrl.value || "").replace(/\/+$/, "");
|
||||
els.remoteUrl.textContent = baseUrl + "/api/manifest/modules/" + els.module.value + "/queries/" + els.query.value + "/execute";
|
||||
}
|
||||
|
||||
function collectArgs() {
|
||||
return Object.fromEntries([...els.params.querySelectorAll("input")].map(input => [input.name, input.value]));
|
||||
}
|
||||
|
||||
function parseManifestValue(value) {
|
||||
const trimmed = String(value).trim();
|
||||
if (trimmed.toLowerCase() === "null") return null;
|
||||
if (trimmed.toLowerCase() === "true") return true;
|
||||
if (trimmed.toLowerCase() === "false") return false;
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectManifestParams() {
|
||||
const query = selectedQuery();
|
||||
const allArgs = collectArgs();
|
||||
return Object.fromEntries((query?.params || []).map(param => [param, parseManifestValue(allArgs[param] || "")]));
|
||||
}
|
||||
|
||||
async function render() {
|
||||
els.status.textContent = "Renderizando...";
|
||||
const data = await api("/api/render", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
moduleId: els.module.value,
|
||||
queryKey: els.query.value,
|
||||
system: els.system.value,
|
||||
args: collectArgs(),
|
||||
}),
|
||||
});
|
||||
els.sql.textContent = data.sql || "";
|
||||
els.status.textContent = "OK";
|
||||
}
|
||||
|
||||
async function login() {
|
||||
els.status.textContent = "Gerando bearer...";
|
||||
const data = await api("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
loginUrl: els.loginUrl.value,
|
||||
email: els.loginEmail.value,
|
||||
senha: els.loginPassword.value,
|
||||
}),
|
||||
});
|
||||
els.bearerToken.value = data.bearerToken || "";
|
||||
els.status.textContent = "Bearer gerado e preenchido";
|
||||
}
|
||||
|
||||
async function executeManifest() {
|
||||
els.status.textContent = "Executando manifesto...";
|
||||
const data = await api("/api/manifest-execute", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: els.baseUrl.value,
|
||||
bearerToken: els.bearerToken.value,
|
||||
clientId: els.clientId.value,
|
||||
moduleId: els.module.value,
|
||||
queryKey: els.query.value,
|
||||
params: collectManifestParams(),
|
||||
}),
|
||||
});
|
||||
const body = data.responseBody;
|
||||
els.sql.textContent = typeof body === "string" ? body : JSON.stringify(body, null, 2);
|
||||
els.status.textContent = (data.ok ? "Manifesto executado" : "Manifesto retornou erro HTTP " + data.status)
|
||||
+ "\\nHTTP " + data.status
|
||||
+ "\\n" + data.url;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
modules = await api("/api/modules");
|
||||
fillSelect(els.module, modules, m => m.id, m => m.id + " (" + m.file + ")");
|
||||
refreshQueries();
|
||||
const docs = await api("/api/docs");
|
||||
els.sql.textContent = docs.markdown;
|
||||
els.status.textContent = "Documentacao carregada";
|
||||
}
|
||||
|
||||
els.module.addEventListener("change", refreshQueries);
|
||||
els.query.addEventListener("change", refreshParams);
|
||||
els.baseUrl.addEventListener("input", refreshRemoteUrl);
|
||||
document.getElementById("render").addEventListener("click", render);
|
||||
document.getElementById("login").addEventListener("click", login);
|
||||
document.getElementById("reload").addEventListener("click", load);
|
||||
document.getElementById("copy").addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(els.sql.textContent);
|
||||
els.status.textContent = "SQL copiada";
|
||||
});
|
||||
document.getElementById("executeManifest").addEventListener("click", executeManifest);
|
||||
document.getElementById("copyResponse").addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(els.sql.textContent);
|
||||
els.status.textContent = "Resposta copiada";
|
||||
});
|
||||
document.getElementById("vet").addEventListener("click", async () => {
|
||||
els.status.textContent = "Rodando vet...";
|
||||
const data = await api("/api/vet", { method: "POST" });
|
||||
els.status.textContent = data.output;
|
||||
});
|
||||
|
||||
load().catch(err => els.status.textContent = err.message);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const handled = await handleApi(req, res);
|
||||
if (handled !== false) return;
|
||||
if (req.method === "GET" && (req.url === "/" || req.url?.startsWith("/?"))) {
|
||||
return html(res, 200, page);
|
||||
}
|
||||
return text(res, 404, "Not found");
|
||||
} catch (error) {
|
||||
return json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
function listen(port: number) {
|
||||
server.once("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EADDRINUSE") listen(port + 1);
|
||||
else throw error;
|
||||
});
|
||||
server.listen(port, () => {
|
||||
const address = server.address();
|
||||
const actualPort = typeof address === "object" && address ? address.port : port;
|
||||
console.log(`Module test app: http://localhost:${actualPort}`);
|
||||
});
|
||||
}
|
||||
|
||||
listen(startPort);
|
||||
Reference in New Issue
Block a user