Pular para o conteúdo
livetest v0.1.0
Instalar

Protocolo de eventos

O daemon publica a mesma sequência de eventos em três canais. Todos veem os mesmos objetos, na mesma ordem, com o mesmo número de sequência, porque os três são apenas assinantes de um único barramento interno.

CanalFormatoConsumidor típico
stdouttexto formatado (pretty) ou NDJSONagente de IA, pessoa no terminal
output.logFileNDJSON append-onlyhistórico, análise posterior
socket TCPNDJSONextensão do VSCode, dashboards

Decisões de projeto

TCP em loopback, não named pipe nem socket Unix. Named pipes do Windows e sockets Unix exigiriam dois caminhos de código e duas formas de descoberta. TCP em 127.0.0.1 é idêntico nas três plataformas.

Porta 0 por padrão. O SO escolhe uma porta livre e o número real vai para o arquivo de descoberta. Dois projetos abertos ao mesmo tempo não colidem.

NDJSON, não um protocolo binário. Um evento por linha é depurável com nc ou tail -f, e qualquer linguagem consegue consumir sem biblioteca.

Snapshot na conexão. O primeiro evento que um cliente recebe é sempre um snapshot com o estado completo. A UI renderiza na hora, sem esperar o próximo lote.

Descoberta

O daemon publica .livetest/daemon.json ao subir e remove no encerramento limpo:

json
{
  "pid": 12345,
  "host": "127.0.0.1",
  "port": 51734,
  "root": "/home/ana/projeto",
  "version": "0.1.0",
  "protocolVersion": 1,
  "startedAt": 1730000000000
}

Um arquivo pode ficar órfão se o daemon morrer abruptamente. Por isso a leitura sempre confere se o processo ainda existe, classificando em quatro estados:

EstadoSignificado
runningarquivo existe e o pid está vivo
stalearquivo existe mas o pid morreu, registro órfão
absentnão há arquivo
invalidJSON quebrado ou campos faltando
ts
import { readDiscoveryFile } from '@livetest/core/client';

const result = readDiscoveryFile('.livetest/daemon.json', root);
if (result.status === 'running') connect(result.info.host, result.info.port);

Enquadramento

Cada evento é um objeto JSON em uma linha, terminado por \n. Um evento grande pode chegar dividido em vários pacotes TCP: acumule até encontrar \n. O core já oferece o divisor pronto (createLineSplitter), e connectToDaemon cuida disso por você.

Campos comuns

Todo evento tem:

CampoTipoSignificado
typestringdiscriminante da união
seqnumbersequência monotônica, começando em 1
timestampnumberepoch em milissegundos

seq 0 não faz parte da sequência

O evento snapshot enviado na conexão usa seq: 0. Ele não faz parte da sequência de eventos ao vivo, é o estado que a precede.

Consumidores devem ignorar type desconhecido. O protocolo é aditivo: novos eventos e novos campos aparecem sem incrementar protocolVersion, que só muda em alteração incompatível.

Eventos

daemon.started e daemon.stopped

json
{ "type": "daemon.started", "seq": 1, "timestamp": 1730000000000,
  "pid": 12345, "root": "/proj", "version": "0.1.0", "protocolVersion": 1,
  "server": { "host": "127.0.0.1", "port": 51734 },
  "configPath": "/proj/livetest.config.json", "indexedFiles": 214 }

O server é null quando o canal está desabilitado ou não pôde ser aberto.

watch.change

Um arquivo observado mudou. O kind é "add", "change" ou "unlink".

batch.started

O debounce fechou o lote e o plano foi calculado. É aqui que mora a transparência: plan[].reasons explica, por arquivo de teste, por que ele entrou.

json
{ "type": "batch.started", "seq": 8, "timestamp": 1730000001000,
  "batchId": "batch-3",
  "changedFiles": ["/proj/src/login.ts"],
  "trigger": "idle",
  "plan": [{
    "runnerKey": "js", "adapterId": "vitest",
    "testFiles": ["/proj/src/header.test.ts"],
    "reasons": {
      "/proj/src/header.test.ts": [{
        "kind": "importer",
        "changedFile": "/proj/src/login.ts",
        "sourceFile": "/proj/src/header.ts",
        "depth": 1,
        "chain": ["/proj/src/login.ts", "/proj/src/header.ts"]
      }]
    }
  }],
  "unmatched": [] }

O trigger é "idle", "maxWindow" ou "manual". O unmatched lista arquivos salvos sem teste correspondente, com a explicação.

Em SelectionReason, kind é "changed" (o próprio arquivo salvo), "importer" (alcançado pelo grafo) ou "manual". O chain é o caminho completo de importação, do arquivo salvo até o arquivo coberto pelo teste.

run.started e run.finished

O run.finished carrega o TestRunResult completo: comando executado, contagens, casos individuais com mensagens de falha, stdoutTail e stderrTail, e os motivos de seleção.

O status de uma execução é passed, failed, errored (não pôde rodar: binário ausente, timeout, config inválida), skipped (nada a rodar ou dryRun) ou cancelled (um lote novo tornou este obsoleto).

batch.finished

Consolida o lote. O status agrega os das execuções, nesta precedência:

output
errored > cancelled > failed > passed > skipped

Um erro de infraestrutura aparece antes de uma falha de teste porque exige uma ação diferente de quem lê.

error

Degradação segura. O daemon continua rodando.

json
{ "type": "error", "seq": 4, "timestamp": 1730000000500,
  "scope": "graph:python",
  "message": "interpretador Python indisponivel (\"python3\")",
  "detail": "spawn python3 ENOENT",
  "degradedTo": "o grafo Python passa a usar analise por expressao regular" }

O degradedTo diz o que passou a valer no lugar. É o requisito de robustez tornado observável.

snapshot

Só no socket, como primeiro evento de cada conexão. Contém DaemonSnapshot: metadados do daemon, running, o lastBatch completo, o estado por arquivo (files) e contadores acumulados (totals).

Consumindo o canal

ts
import { connectToDaemon } from '@livetest/core/client';

const connection = await connectToDaemon({
  root: '/proj',
  onEvent: (event) => {
    if (event.type === 'snapshot') renderizarTudo(event.state);
    if (event.type === 'batch.finished') atualizar(event.result);
  },
  onClose: (motivo) => reconectarDepois(motivo),
});

O connectToDaemon lança LiveTestError com código DAEMON_NOT_RUNNING (não há daemon) ou CONNECTION_FAILED (o socket não abriu). Distinguir os dois importa: o primeiro significa "ofereça o botão de iniciar", o segundo, "tente de novo".

Sem TypeScript, o canal é só um socket de linhas:

bash
PORT=$(jq -r .port .livetest/daemon.json)
nc 127.0.0.1 "$PORT" | jq -c 'select(.type == "batch.finished") | .result.status'