diff --git a/migrations/20260507000001_seq_tb_flash_fato_contribuicao.sql b/migrations/C5_big/20260507000001_seq_tb_flash_fato_contribuicao.sql similarity index 100% rename from migrations/20260507000001_seq_tb_flash_fato_contribuicao.sql rename to migrations/C5_big/20260507000001_seq_tb_flash_fato_contribuicao.sql diff --git a/migrations/20260507000002_tb_flash_fato_contribuicao.sql b/migrations/C5_big/20260507000002_tb_flash_fato_contribuicao.sql similarity index 100% rename from migrations/20260507000002_tb_flash_fato_contribuicao.sql rename to migrations/C5_big/20260507000002_tb_flash_fato_contribuicao.sql diff --git a/migrations/20260507000009_seq_tb_flash_nodo_resumo_contribuicao.sql b/migrations/C5_big/20260507000009_seq_tb_flash_nodo_resumo_contribuicao.sql similarity index 100% rename from migrations/20260507000009_seq_tb_flash_nodo_resumo_contribuicao.sql rename to migrations/C5_big/20260507000009_seq_tb_flash_nodo_resumo_contribuicao.sql diff --git a/migrations/20260507000010_tb_flash_nodo_resumo_contribuicao.sql b/migrations/C5_big/20260507000010_tb_flash_nodo_resumo_contribuicao.sql similarity index 100% rename from migrations/20260507000010_tb_flash_nodo_resumo_contribuicao.sql rename to migrations/C5_big/20260507000010_tb_flash_nodo_resumo_contribuicao.sql diff --git a/migrations/20260507000011_prc_flash_carga_resumo_contribuicao.sql b/migrations/C5_big/20260507000011_prc_flash_carga_resumo_contribuicao.sql similarity index 100% rename from migrations/20260507000011_prc_flash_carga_resumo_contribuicao.sql rename to migrations/C5_big/20260507000011_prc_flash_carga_resumo_contribuicao.sql diff --git a/migrations/20260507000012_prc_flash_atualiza_contribuicao_dono.sql b/migrations/C5_big/20260507000012_prc_flash_atualiza_contribuicao_dono.sql similarity index 100% rename from migrations/20260507000012_prc_flash_atualiza_contribuicao_dono.sql rename to migrations/C5_big/20260507000012_prc_flash_atualiza_contribuicao_dono.sql diff --git a/migrations/20260507000013_prc_flash_carga_dados_contribuicao.sql b/migrations/C5_big/20260507000013_prc_flash_carga_dados_contribuicao.sql similarity index 100% rename from migrations/20260507000013_prc_flash_carga_dados_contribuicao.sql rename to migrations/C5_big/20260507000013_prc_flash_carga_dados_contribuicao.sql diff --git a/migrations/20260507000014_tb_flash_meta_contribuicao_stg.sql b/migrations/C5_big/20260507000014_tb_flash_meta_contribuicao_stg.sql similarity index 100% rename from migrations/20260507000014_tb_flash_meta_contribuicao_stg.sql rename to migrations/C5_big/20260507000014_tb_flash_meta_contribuicao_stg.sql diff --git a/migrations/20260507000015_vw_flash_meta_contribuicao_stg.sql b/migrations/C5_big/20260507000015_vw_flash_meta_contribuicao_stg.sql similarity index 100% rename from migrations/20260507000015_vw_flash_meta_contribuicao_stg.sql rename to migrations/C5_big/20260507000015_vw_flash_meta_contribuicao_stg.sql diff --git a/migrations/C5_big/20260507000016_seq_tb_flash_meta_contribuicao.sql b/migrations/C5_big/20260507000016_seq_tb_flash_meta_contribuicao.sql new file mode 100644 index 0000000..27bfb84 --- /dev/null +++ b/migrations/C5_big/20260507000016_seq_tb_flash_meta_contribuicao.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin +DECLARE + v_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_count FROM user_sequences WHERE sequence_name = 'SEQ_TB_FLASH_META_CONTRIBUICAO'; + IF v_count = 0 THEN + EXECUTE IMMEDIATE 'CREATE SEQUENCE SEQ_TB_FLASH_META_CONTRIBUICAO START WITH 1 INCREMENT BY 1 NOCACHE NOCYCLE'; + END IF; +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DECLARE + v_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_count FROM user_sequences WHERE sequence_name = 'SEQ_TB_FLASH_META_CONTRIBUICAO'; + IF v_count > 0 THEN + EXECUTE IMMEDIATE 'DROP SEQUENCE SEQ_TB_FLASH_META_CONTRIBUICAO'; + END IF; +END; +-- +goose StatementEnd diff --git a/migrations/C5_big/20260507000017_tb_flash_meta_contribuicao.sql b/migrations/C5_big/20260507000017_tb_flash_meta_contribuicao.sql new file mode 100644 index 0000000..c85e469 --- /dev/null +++ b/migrations/C5_big/20260507000017_tb_flash_meta_contribuicao.sql @@ -0,0 +1,80 @@ +-- +goose Up +-- +goose StatementBegin +DECLARE + v_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_count FROM user_tables WHERE table_name = 'TB_FLASH_META_CONTRIBUICAO'; + IF v_count = 0 THEN + EXECUTE IMMEDIATE ' + CREATE TABLE TB_FLASH_META_CONTRIBUICAO ( + ID_META NUMBER NOT NULL, + ID_NODO NUMBER NOT NULL, + DATA_REFERENCIA DATE NOT NULL, + ANO_REFERENCIA NUMBER(4) NOT NULL, + MES_REFERENCIA NUMBER(2) NOT NULL, + DIA_REFERENCIA NUMBER(2) NOT NULL, + PERCENTUAL_META NUMBER(10,4), + VALOR_META NUMBER(18,6), + OBSERVACAO VARCHAR2(500), + DATA_CADASTRO DATE DEFAULT SYSDATE NOT NULL, + DATA_ATUALIZACAO DATE, + CONSTRAINT PK_TB_FLASH_META_CONTRIBUICAO PRIMARY KEY (ID_META), + CONSTRAINT FK_TB_FLASH_META_CONTRIBUICAO_NODO FOREIGN KEY (ID_NODO) REFERENCES TB_FLASH_NODO (ID_NODO), + CONSTRAINT CK_TB_FLASH_META_CONTRIBUICAO_MES CHECK (MES_REFERENCIA BETWEEN 1 AND 12), + CONSTRAINT CK_TB_FLASH_META_CONTRIBUICAO_DIA CHECK (DIA_REFERENCIA BETWEEN 1 AND 31) + )'; + END IF; +END; +-- +goose StatementEnd +-- +goose StatementBegin +DECLARE + v_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_count FROM user_constraints WHERE constraint_name = 'UK_TB_FLASH_META_CONTRIBUICAO'; + IF v_count = 0 THEN + EXECUTE IMMEDIATE 'ALTER TABLE TB_FLASH_META_CONTRIBUICAO ADD CONSTRAINT UK_TB_FLASH_META_CONTRIBUICAO UNIQUE (ID_NODO, DATA_REFERENCIA)'; + END IF; +END; +-- +goose StatementEnd +-- +goose StatementBegin +DECLARE + v_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_count FROM user_indexes WHERE index_name = 'IX_TB_FLASH_META_CONTRIBUICAO_NODO'; + IF v_count = 0 THEN + EXECUTE IMMEDIATE 'CREATE INDEX IX_TB_FLASH_META_CONTRIBUICAO_NODO ON TB_FLASH_META_CONTRIBUICAO (ID_NODO)'; + END IF; +END; +-- +goose StatementEnd +-- +goose StatementBegin +DECLARE + v_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_count FROM user_indexes WHERE index_name = 'IX_TB_FLASH_META_CONTRIBUICAO_DATA'; + IF v_count = 0 THEN + EXECUTE IMMEDIATE 'CREATE INDEX IX_TB_FLASH_META_CONTRIBUICAO_DATA ON TB_FLASH_META_CONTRIBUICAO (DATA_REFERENCIA)'; + END IF; +END; +-- +goose StatementEnd +-- +goose StatementBegin +DECLARE + v_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_count FROM user_indexes WHERE index_name = 'IX_TB_FLASH_META_CTB_ANOMESDIA'; + IF v_count = 0 THEN + EXECUTE IMMEDIATE 'CREATE INDEX IX_TB_FLASH_META_CTB_ANOMESDIA ON TB_FLASH_META_CONTRIBUICAO (ANO_REFERENCIA, MES_REFERENCIA, DIA_REFERENCIA)'; + END IF; +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DECLARE + v_count NUMBER; +BEGIN + SELECT COUNT(*) INTO v_count FROM user_tables WHERE table_name = 'TB_FLASH_META_CONTRIBUICAO'; + IF v_count > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TB_FLASH_META_CONTRIBUICAO CASCADE CONSTRAINTS PURGE'; + END IF; +END; +-- +goose StatementEnd diff --git a/package.json b/package.json index 3235332..77a9152 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "generate": "jeff generate -i \"src/**/*.module.ts\"", "generate:output": "jeff generate -i \"src/**/*.module.ts\" -o dist/manifest.json", "vet": "jeff vet -i \"src/**/*.module.ts\"", + "test:app": "tsx scripts/module-test-app.ts", "jeff": "jeff" }, "author": "", diff --git a/scripts/assets/logo-davinti.jpg b/scripts/assets/logo-davinti.jpg new file mode 100644 index 0000000..2701cb7 Binary files /dev/null and b/scripts/assets/logo-davinti.jpg differ diff --git a/scripts/module-test-app.md b/scripts/module-test-app.md new file mode 100644 index 0000000..366125f --- /dev/null +++ b/scripts/module-test-app.md @@ -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. diff --git a/scripts/module-test-app.ts b/scripts/module-test-app.ts new file mode 100644 index 0000000..1572256 --- /dev/null +++ b/scripts/module-test-app.ts @@ -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; + implementations: Record) => { sql: string }>>; + schedules?: Record; +}; + +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 { + 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 = { + 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).token === "string" && + (value as Record).token.trim() + ) { + return (value as Record).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) { + 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 = { + "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` + + + + + App Dono Modulos - Teste Local + + + +
+
+
+ +
+ App Dono Modulos + Teste local de queries e manifesto remoto +
+
+
Vitruvio manifest tools
+
+
+
+
+
+

Modulo de teste

+ Local + remoto +
+
+

Selecao

+ + + + + + +
Os valores sao expressoes SQL cruas. Para testar literal de data, use aspas: '2026-05-08'.
+
+
+

Autenticacao

+ + +
+ + +
+ + + + + +
+
+

Parametros

+
+
+
+

Manifesto

+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

+    
+
+ + +`; + +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);