Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/report-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: QA de relatorios e acervo
on:
pull_request:
branches: [main]
paths: ['backend/**', 'frontend/**', 'ai-service/**', 'database/**', 'e2e/**', '.github/workflows/report-review.yml']
workflow_dispatch:
jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
E2E_CHROME_PATH: /usr/bin/google-chrome
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: |
backend/package-lock.json
frontend/package-lock.json
e2e/package-lock.json
- name: Instalar dependencias de QA
run: |
npm ci --prefix backend
npm ci --prefix frontend
npm ci --prefix e2e
- name: Compilar e testar contratos e interface
run: |
npm run build --prefix backend
npm test --prefix backend
npm run build --prefix frontend
npm test --prefix frontend
- name: Atomicidade, concorrencia, idempotencia e proveniencia no PostgreSQL
run: npm run test:integration:indexing --prefix backend
- name: Busca, aprovacao e expurgo no navegador autenticado
run: npm run test:integration:review --prefix backend
- name: Harness real e regressao do copiloto
run: |
docker build -t sinapse-s209-ai-test ai-service
npm run test:integration:copilot --prefix backend
8 changes: 7 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"migrate": "tsx src/database/migrate.ts",
"bootstrap:admin": "tsx scripts/bootstrap-admin.mts"
"bootstrap:admin": "tsx scripts/bootstrap-admin.mts",
"test": "node scripts/test.mjs",
"test:integration:indexing": "tsx scripts/test-indexing-db.mts",
"test:integration:harness": "tsx scripts/validate-harness.mts",
"test:integration:copilot": "tsx scripts/validate-harness.mts --browser",
"test:integration:review": "tsx scripts/test-browser-qa.mts",
"audit:security": "npm audit --omit=dev"
},
"dependencies": {
"@types/multer": "^2.3.0",
Expand Down
19 changes: 19 additions & 0 deletions backend/scripts/harness-deadline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// AbortSignal.timeout usa um timer sem referência; sozinho ele não mantém o processo de teste ativo.
export async function withHarnessDeadline(operation, timeoutMs = 5000) {
const controller = new AbortController();
let timer;
try {
return await Promise.race([
Promise.resolve().then(() => operation(controller.signal)),
new Promise((_, reject) => {
timer = setTimeout(() => {
const error = new Error('Tempo limite na comunicação com o serviço de teste.');
controller.abort(error);
reject(error);
}, timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
129 changes: 129 additions & 0 deletions backend/scripts/harness-model.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { createServer } from 'node:http';
export function createHarnessModel(token) {
if (!token)
throw new Error('Token obrigatório');
const state = { invalid: false, clarificationOnly: false, unavailable: false, delayMs: 0, captures: [] };
const MAX_BODY = 256 * 1024;
const server = createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json');
const prefix = '/' + token + '/';
if (!req.url?.startsWith(prefix)) {
res.writeHead(401);
res.end('{}');
return;
}
const route = '/' + req.url.slice(prefix.length);
if (req.method === 'GET') {
res.end(JSON.stringify(route === '/control' ? state : { models: [] }));
return;
}
let size = 0;
const parts = [];
try {
for await (const part of req) {
size += part.length;
if (size > MAX_BODY) {
res.writeHead(413);
res.end('{"error":"Corpo excede o limite"}');
return;
}
parts.push(part);
}
const payload = JSON.parse(Buffer.concat(parts).toString('utf8'));
if (route === '/control') {
for (const key of ['invalid', 'clarificationOnly', 'unavailable'])
if (typeof payload[key] === 'boolean')
state[key] = payload[key];
if (Number.isInteger(payload.delayMs) && payload.delayMs >= 0 && payload.delayMs <= 10000)
state.delayMs = payload.delayMs;
res.end(JSON.stringify(state));
return;
}
if (route === '/api/embeddings') {
res.statusCode = state.unavailable ? 503 : 200;
res.end(JSON.stringify(state.unavailable ? { error: 'Indisponivel' } : { embedding: Array(1024).fill(0.1) }));
return;
}
state.captures.push(payload);
if (state.captures.length > 200)
state.captures.shift();
const data = JSON.parse(payload.prompt);
if (state.unavailable) {
res.statusCode = 503;
res.end(JSON.stringify({ error: "Indisponível" }));
return;
}
if (state.delayMs)
await new Promise((resolve) => setTimeout(resolve, state.delayMs));
const proposalSchema = payload.format.properties.proposal;
const scenario = (nome, tipo, entao) => ({
nome,
tipo,
dado: "que existe um pedido registrado",
quando: "o usuário solicita a aprovação",
entao,
});
const result = state.invalid
? { questions: [], summary: "SEGREDO", extra: "proibido" }
: state.clarificationOnly && payload.format.properties.scenarios
? { scenarios: [], questions: ["Qual o resultado esperado quando o pedido nao existir?"] }
: payload.format.properties.scenarios
? {
scenarios: [
scenario("Aprovar pedido com permissão", "caminho_principal", "o pedido passa a constar como aprovado"),
...(payload.system.includes("obrigatoriamente")
? [
scenario("Recusar aprovação sem permissão", "acesso_nao_autorizado", "o sistema recusa a ação e o pedido permanece inalterado"),
]
: []),
],
questions: [],
}
: proposalSchema
? {
proposal: Object.fromEntries(Object.keys((proposalSchema.anyOf
? proposalSchema.anyOf.find((item) => item.type === "object")
: proposalSchema).properties).map((field) => [
field,
field === "titulo"
? "Consultar pagamento padronizado"
: field === "regras_observacoes"
? ""
: data.fields[field] || "Resultado funcional do produto",
])),
questions: [],
}
: payload.format.properties.questions
? {
questions: data.fields.titulo?.includes("pagamento")
? [
{
dimension: "validacoes",
question: "Qual o tempo máximo para considerar a consulta rápida?",
},
{
dimension: "permissoes_seguranca",
question: "Quem pode consultar o pagamento?",
},
]
: [],
summary: payload.system.includes("orientacao-v2")
? "Nova revisão"
: "Revisão inicial",
}
: { answer: "Evidência do projeto", sources: [data.context[0].id] };
res.end(JSON.stringify({ response: JSON.stringify(result) }));
}
catch {
if (!res.headersSent)
res.writeHead(400);
res.end('{"error":"Requisição inválida"}');
}
});
server.requestTimeout = 5000;
server.headersTimeout = 5000;
// Acesso pela rede Docker temporária; a porta publicada no host usa apenas loopback.
return server;
}
if (process.env.HARNESS_TOKEN)
createHarnessModel(process.env.HARNESS_TOKEN).listen(11434, '0.0.0.0');
69 changes: 69 additions & 0 deletions backend/scripts/test-browser-qa.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { spawn, spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Client } from 'pg';
import { applyUntil } from '../src/database/migration-test-utils.js';

const id = 'sinapse-review-' + randomUUID().slice(0, 8);
const password = randomUUID();
const storage = await mkdtemp(join(tmpdir(), id + '-'));
let started = false;
let embeddings: import('node:http').Server | undefined;
let api: ReturnType<typeof import('node:http').createServer> | undefined;
let web: { httpServer: import('node:http').Server } | undefined;
let db: { end(): Promise<void> } | undefined;
function docker(args: string[]) {
const result = spawnSync('docker', args, { encoding: 'utf8' });
if (result.status !== 0) throw new Error('Docker indisponível para o QA isolado.');
return result.stdout.trim();
}
try {
docker(['run', '-d', '--name', id, '-e', 'POSTGRES_USER=sinapse', '-e', 'POSTGRES_PASSWORD=' + password,
'-e', 'POSTGRES_DB=sinapse_e2e_test', '-p', '127.0.0.1::5432', 'pgvector/pgvector:pg16']);
started = true;
const port = JSON.parse(docker(['inspect', id]))[0].NetworkSettings.Ports['5432/tcp'][0].HostPort;
const url = 'postgresql://sinapse:' + password + '@127.0.0.1:' + port + '/sinapse_e2e_test';
let ready = false;
for (let attempt = 0; attempt < 40; attempt++) {
if (spawnSync('docker', ['exec', id, 'pg_isready', '-h', '127.0.0.1', '-U', 'sinapse'], { stdio: 'ignore' }).status === 0) { ready = true; break; }
await new Promise(r => setTimeout(r, 500));
}
if (!ready) throw new Error('PostgreSQL não iniciou.');
Object.assign(process.env, { NODE_ENV: 'test', DATABASE_URL: url, E2E_DATABASE_URL: url,
DOCUMENT_STORAGE_DIR: storage, AI_SERVICE_URL: 'http://127.0.0.1:9', REPO_ANALYZER_URL: 'http://127.0.0.1:9' });
const migration = new Client({ connectionString: url });
await migration.connect();
try { await applyUntil(migration, true, () => false); } finally { await migration.end(); }
const { startEmbeddingService } = await import('../../e2e/embedding-service.mjs');
const embeddingService = await startEmbeddingService();
embeddings = embeddingService.server;
process.env.AI_SERVICE_URL = embeddingService.url;
const { app } = await import('../src/app.js');
const { pool } = await import('../src/database/db.js');
db = pool;
api = app.listen(0, '127.0.0.1');
await new Promise<void>(r => api!.once('listening', r));
const apiUrl = 'http://127.0.0.1:' + (api.address() as { port: number }).port;
const { preview } = await import('../../frontend/node_modules/vite/dist/node/index.js');
const frontend = await preview({ root: resolve('../frontend'), configFile: false,
preview: { host: '127.0.0.1', port: 5190, strictPort: true, proxy: { '/api': { target: apiUrl, changeOrigin: true } } } });
web = frontend;
process.exitCode = await new Promise<number>((resolveExit, reject) => {
const child = spawn(process.execPath, ['run.mjs'], { cwd: resolve('../e2e'), stdio: 'inherit',
env: { ...process.env, E2E_API_URL: apiUrl + '/api/v1', E2E_APP_URL: 'http://127.0.0.1:5190',
...(process.platform === 'win32' && !process.env.E2E_CHROME_PATH ? { E2E_CHROME_PATH: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' } : {}) } });
child.once('error', reject);
child.once('exit', code => resolveExit(code ?? 1));
});
} finally {
if (web) await new Promise<void>(r => web!.httpServer.close(() => r()));
if (api) { api.closeAllConnections(); await new Promise<void>(r => api!.close(() => r())); }
if (db) await db.end();
if (embeddings) { embeddings.closeAllConnections(); await new Promise<void>(r => embeddings!.close(() => r())); }
if (started) docker(['rm', '-f', '-v', id]);
if (!storage.startsWith(join(tmpdir(), id + '-'))) throw new Error('Diretório temporário inesperado.');
await rm(storage, { recursive: true, force: true });
console.log('Banco e arquivos temporários de QA removidos.');
}
75 changes: 75 additions & 0 deletions backend/scripts/test-indexing-db.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createServer, type Server } from "node:http";
import { spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { Client } from "pg";
import { applyUntil } from "../src/database/migration-test-utils.js";

const name = "sinapse-s203-test-" + randomUUID().slice(0,8);
const password = randomUUID();
function docker(args: string[]) {
const result = spawnSync("docker", args, { encoding: "utf8" });
if (result.status !== 0) throw new Error("Não foi possível preparar o PostgreSQL de testes.");
return result.stdout.trim();
}
let started = false;
let bridge: Server | undefined;
try {
docker(["run","-d","--name",name,"-e","POSTGRES_USER=sinapse","-e","POSTGRES_PASSWORD="+password,
"-e","POSTGRES_DB=sinapse_s203_test","-p","127.0.0.1::5432","pgvector/pgvector:pg16"]);
started = true;
const port = JSON.parse(docker(["inspect",name]))[0].NetworkSettings.Ports["5432/tcp"][0].HostPort;
const url = "postgresql://sinapse:"+password+"@127.0.0.1:"+port+"/sinapse_s203_test";
let ready = false;
for (let i=0;i<40;i++) {
const result=spawnSync("docker",["exec",name,"pg_isready","-h","127.0.0.1","-U","sinapse","-d","sinapse_s203_test"],{encoding:"utf8"});
if (result.status===0) { ready=true; break; }
await new Promise(r=>setTimeout(r,500));
}
if (!ready) throw new Error("PostgreSQL não ficou pronto.");
const client = new Client({connectionString:url});
await client.connect();
try { await applyUntil(client,true,()=>false); } finally { await client.end(); }
console.log("Todas as migrações aplicadas em banco isolado com pgvector.");
let realEndpoint = "";
if (process.argv.includes("--real-ai")) {
bridge = createServer(async (req, res) => {
const buffers = [];
for await (const part of req) buffers.push(part);
const python = [
"import os,sys,urllib.request",
"payload=sys.stdin.buffer.read()",
"request=urllib.request.Request('http://127.0.0.1:8000/ingest/entity',data=payload,headers={'Content-Type':'application/json','X-Service-Token':os.environ['AI_SERVICE_TOKEN']})",
"response=urllib.request.urlopen(request,timeout=120)",
"sys.stdout.buffer.write(response.read())",
].join("\n");
const response = spawnSync("docker", ["exec","-i","sinapse-ai-service","python","-c",python], {
input: Buffer.concat(buffers), maxBuffer: 8 * 1024 * 1024, timeout: 125000,
});
res.setHeader("Content-Type", "application/json");
res.statusCode = response.status === 0 ? 200 : 502;
res.end(response.status === 0 ? response.stdout : JSON.stringify({ error: "Serviço IA indisponível." }));
});
await new Promise<void>(r => bridge!.listen(0, "127.0.0.1", r));
realEndpoint = "http://127.0.0.1:" + (bridge.address() as { port: number }).port;
}
const exitCode = await new Promise<number>((resolve, reject) => {
const child = spawn(process.execPath,["--import","tsx","--test",
"src/modules/indexing/indexing.db.test.ts","src/modules/indexing/indexing.migration.db.test.ts","src/modules/indexing/indexing.transport.db.test.ts","src/modules/indexing/indexing.state.db.test.ts","src/modules/indexing/indexing.recovery.db.test.ts","src/modules/indexing/indexing.worker.test.ts","src/modules/indexing/indexing.integration.db.test.ts","src/modules/repo-analyses/repo-analyses.indexing.db.test.ts"],{
env:{...process.env,NODE_ENV:"test",DATABASE_URL:url,INDEX_TEST_DATABASE_URL:url,INDEX_AI_ENDPOINT:realEndpoint},
stdio:"inherit",
});
child.once("error", reject);
child.once("exit", code => resolve(code ?? 1));
});
process.exitCode=exitCode;
if (exitCode === 0) {
const suggestions = spawnSync(process.execPath, ["--import", "tsx", "--test", "src/modules/suggestions/suggestions.db.test.ts"], {
env: { ...process.env, NODE_ENV: "test", DATABASE_URL: url, ARCHIVE_TEST_DATABASE_URL: url },
stdio: "inherit",
});
process.exitCode = suggestions.status ?? 1;
}
} finally {
if (bridge) { bridge.closeAllConnections(); await new Promise<void>(r => bridge!.close(() => r())); }
if (started) { docker(["rm","-f","-v",name]); console.log("Banco temporário removido."); }
}
22 changes: 22 additions & 0 deletions backend/scripts/test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { spawnSync } from "node:child_process";
import { readdirSync } from "node:fs";
import { join } from "node:path";

function discoverTests(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return discoverTests(path);
return entry.name.endsWith(".test.ts") ? [path] : [];
});
}

const files = discoverTests("src").sort();
if (files.length === 0) throw new Error("Nenhum teste de backend encontrado.");

const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...files], {
env: { ...process.env, NODE_ENV: "test" },
stdio: "inherit",
});

if (result.error) throw result.error;
process.exit(result.status ?? 1);
Loading
Loading