Migrando para nukadoko
Para que serve
Uma suíte existente migrando para nukadoko, uma peça de cada vez — não uma reescrita. O ponto de partida difere:
- Uma suíte cucumber-js — arquivos de feature mais cola (glue), geralmente dirigindo Playwright.
- Uma suíte já moldada como passos tipados — sua própria DSL, não a do cucumber, mas próxima o suficiente de
defineStepdo nukadoko para que a migração seja principalmente tradução.
De qualquer forma, a migração ocorre em estágios, nunca de uma só vez; o restante desta skill explica o porquê e, em seguida, detalha cada ponto de partida por vez.
Tudo abaixo pressupõe que o projeto já está inicializado. Se ainda não estiver (sem nukadoko.config.ts), execute nuka init primeiro.
Dois estágios, nunca de uma só vez
Altere uma coisa, depois a próxima — nunca ambas ao mesmo tempo. Se um passo quebrar após duas alterações serem aplicadas juntas, não há como saber qual delas o quebrou: as causas se misturam em uma única falha. Dividir o trabalho em estágios mantém cada falha rastreável a exatamente uma alteração. Esse é o objetivo principal — os próprios estágios são secundários a isso.
Se os estágios abaixo não se encaixarem no seu ponto de partida, derive seus próprios dois estágios a partir deste princípio. O que deve ser mantido é a rastreabilidade, não esses nomes de estágios específicos.
Duas aplicações seguem: uma suíte cucumber-js se divide em "trocar o import" e "adicionar tipagem" (abaixo); uma suíte já moldada como passos tipados se divide de forma diferente, descrita mais adiante.
Vindo de uma suíte cucumber-js
Este é o caso para o qual os dois estágios acima são nomeados.
Estágio 1 — fazer funcionar
Altere o único import que cada arquivo de cola (glue) usa:
// antes: import { Given, When, Then } from "@cucumber/cucumber";
import { Given, When, Then } from "nukadoko/compat";
A extensão decide o que a descoberta lê, não o histórico da suíte: arquivos de cola .ts, .mts, .js e .mjs são lidos da mesma forma, então uma suíte que sempre foi JavaScript puro não precisa ser reescrita para TypeScript apenas para iniciar este estágio. Um arquivo .cjs é a única exceção: nukadoko é exclusivamente ESM, então a descoberta nunca importa um; nuka check o nomeia em vez disso (step-file-unsupported-extension) em vez de deixá-lo desaparecer como um undefined-step inexplicável. Renomeá-lo para .js só ajuda se o código do próprio arquivo já for sintaxe de módulo ES; se ainda chamar require(...), isso falha no mesmo import pelo mesmo motivo de ser exclusivamente ESM, e nuka check informa isso como step-file-import-failed.
Em seguida, execute nuka check <feature> e nuka run <feature> e leia o que eles dizem. Corrija o que eles apontarem e execute-os novamente. Repita até que a suíte existente esteja verde — essa é a condição de conclusão do Estágio 1, nada mais. Essa condição é nuka check e nuka run ficarem verdes, não tsc verificando tipos de forma limpa; os dois podem discordar em qualquer direção.
nuka steps e nuka describe permanecem utilizáveis durante todo o Estágio 1, mesmo enquanto alguns arquivos de cola ainda falham ao importar: ambos leem arquivos de passos um de cada vez, então um arquivo que ainda falha ao importar é nomeado (import_failures em --json, stderr caso contrário) em vez de esvaziar todo o vocabulário. Leia o vocabulário à medida que avança, em vez de esperar que todos os arquivos importem de forma limpa primeiro; nuka run <feature> e nuka do <step> ainda se recusam completamente em um arquivo de cola ilegível, intencionalmente, já que estão prestes a executar.
Não procure uma lista do que o compat não suporta antes de começar. O que não funcionar falhará de forma ruidosa, seja no import ou no primeiro nuka run — a falha nomeia o que quebrou, e é nisso que você deve agir. Uma lista escrita aqui ficaria desatualizada assim que a cobertura do compat crescer; a saída do próprio CLI nunca fica.
Estágio 2 — dar contratos a ele
Depois que a suíte estiver funcionando, alguns passos valem a pena serem tipados. Isso é o que muda quando um deles é:
| passo compat | passo tipado | |
|---|---|---|
| Entrada | apenas captura de padrão, não verificada | validada contra um esquema args, cada campo com um .describe() |
| Saída | descartada — o result do recibo é null | validada contra um esquema returns e armazenada no recibo |
| Dependências | efeitos colaterais no World, invisíveis na assinatura da função | declaradas com from, nomeadas em um import, verificadas por nuka check antes de qualquer coisa ser executada e registradas como used no recibo |
| Palavra-chave | decorativa — um passo vinculado a Then ainda pode sofrer mutação | mutates é uma declaração em que nukadoko confia: declare mutates: true e um ambiente somente leitura se recusa a executá-lo, e nuka check avisa se estiver vinculado a Then; o que realmente foi executado ainda é registrado nas contagens observed do recibo |
| Execução isolada | não é possível (o World está vazio fora de um cenário) | nuka do <step> o executa diretamente, recibo impresso no stdout; uma chave from vem de --args como qualquer outra, ou de --use <receipt-id> para uma extraída de uma execução anterior; uma chamada de fixture resultOf dentro de run ainda não encontra nada, já que não há cenário para percorrer |
Essa última linha é um fato separado da linha "Dependências" acima, não uma consequência dela: um passo compat não pode ser executado sozinho porque o que ele precisa está em um World que ninguém populou ainda, e o World não faz parte de sua assinatura — não há nada para inspecionar para saber o que configurar primeiro. As dependências de um passo tipado são nomeadas como entradas from, visíveis em um import, então uma chamada nuka do pode fornecê-las manualmente: --args para uma chave comum, ou --use <receipt-id> para uma extraída do resultado de uma execução anterior — o nome do próprio passo upstream nunca precisa aparecer na linha de comando, porque o recibo citado já o carrega. Um passo cujas todas as chaves chegam dessa forma não precisa de --args: --use por si só é uma invocação completa. O que ainda não encontra nada fora de um cenário é uma dependência lida através da fixture resultOf de dentro de run: essa chamada não tem cadeia para percorrer quando não houve cenário para construir uma, com ou sem --use.
Promova primeiro os passos que você executa com mais frequência, um de cada vez — não a suíte inteira em uma única passada. Como reescrever qualquer peça de cola é uma decisão a ser tomada no momento; nukadoko não prescreve uma receita única, e esta skill também não o fará.
Ao redigir o run de um passo tipado, às vezes se cai no formato não migrado run(ctx, args) antes de ser completamente desestruturado. nuka steps --json já nomeia quais fixtures esse rascunho toca, como needs_inferred: um palpite léxico lido dos acessos de membros do próprio ctx, junto com needs: null e um needs_error explicando por que o contrato real ainda não pode ser lido. Isso evita reler todo o corpo manualmente para saber o que adicionar à assinatura desestruturada, mas é um palpite, não o contrato: ele perde um alias (const c = ctx) e nunca nomeia needs_browser, então termine a desestruturação em vez de tratá-la como concluída.
A aparência de um passo promovido
Um exemplo. Antes, cola compat:
const { Given } = require("@cucumber/cucumber");
Given("um projeto {string} existe", async function (name) {
const res = await this.request.post("/projects", { data: { name } });
this.projectId = (await res.json()).id;
});
Depois, um passo tipado (features/steps/create-project.ts):
import { defineStep } from "nukadoko";
import { z } from "zod";
export default defineStep({
pattern: "um projeto {name:string} existe",
description: "Criar um projeto e retornar seu id",
args: z.object({
name: z.string().describe("o nome de exibição do projeto"),
}),
returns: z.object({
id: z.string().describe("o id do projeto criado, para passos posteriores citarem"),
}),
mutates: true,
async run({ request }, args) {
const res = await request.post("/projects", { data: args });
return res.json();
},
});
O que mudou:
this.projectIddesapareceu; o id retorna através dereturns. Um passo posterior declarafrom: { projectId: [createProjectStep, "id"] }para lê-lo por chave —nuka checkverifica a ordem de vinculação antes de qualquer execução, e a leitura aparece no recibo desse passo posterior (a fixtureresultOfpermanece disponível para a leitura mais rara que um nome de chave não pode expressar).- O argumento é uma captura nomeada vinculada a uma chave de esquema (
{name:string}→args.name), então o padrão sozinho mostra qual texto se torna qual campo. mutates: trueagora é uma declaração, não apenas um fato sobre o que o código faz —nuka checkavisa se este passo for vinculado aThen, e um ambiente somente leitura se recusa a executá-lo completamente. O recibo ainda registra o que a execução realmente enviou, para revisão, mas essa contagem não faz o passo ser rejeitado.this.requestse torna a fixturerequest, nomeada pela desestruturação do primeiro argumento: apenas o que o executor realmente injeta pode ser nomeado lá, nada implícito, e apenas os nomes realmente nomeados são construídos. Um passo que não nomeia nempagenemcontextnunca inicia um navegador.
Este é o único exemplo trabalhado aqui. Não é um catálogo de todas as lacunas entre compat e tipado — se outro padrão surgir com frequência suficiente para merecer um, isso é uma adição separada, não algo a ser improvisado a partir deste único caso.
Quando um hook After global era a limpeza
Uma suíte cucumber-js geralmente depende de um hook After global para limpar o que um passo criou, um inquilino, uma linha semeada, um arquivo temporário. Vale a pena revisitar isso assim que o passo que o criou for promovido: declare o recurso como uma fixture sob fixtures do próprio nukadoko.config.ts, com a limpeza escrita logo após await use(...) na mesma função que o construiu, em vez de deixada em um hook que precisa adivinhar, apenas por tags, quais cenários realmente precisam dela. defineFixtures (do pacote nukadoko) mantém a fixture totalmente tipada; qualquer passo tipado a alcança da mesma forma que alcança page ou request, desestruturando o nome. Isso é aditivo, não obrigatório: um hook After que limpa algo que nenhum passo tipado reivindicou ainda é exatamente tão válido quanto era no Estágio 1.
Vindo de uma DSL moldada como passos tipados
Se não houver arquivos de feature e nenhuma cola cucumber, a porta compat do Estágio 1 acima não é relevante — pule direto para além dela.
O que torna isso possível é que pattern é opcional em defineStep: um passo pode ser definido sem nenhum padrão e ainda ser uma peça completa de vocabulário apenas via CLI, executável com nuka do e inspecionável com nuka describe. Isso é um par diferente de estágios produzidos pelo mesmo princípio acima: mova cada passo para um defineStep tipado primeiro e agrupe-o em um pattern Gherkin depois, sempre que um arquivo de feature tornar isso válido.
Se a DSL de origem já carrega algo como description, args, returns, mutates e uma função run, a tradução para defineStep é direta — cada um tem um correspondente em defineStep para recebê-lo.
O que não fazer
- Não faça o Estágio 1 e o Estágio 2 ao mesmo tempo. Misturar uma troca de import com tipagem adicionada na mesma alteração é exatamente o que torna uma falha não atribuível — veja "Dois estágios, nunca de uma só vez" acima.
- Não deixe a suíte parar de funcionar enquanto está parcialmente migrada. Passos compat e tipados coexistem no mesmo arquivo de feature; uma suíte com alguns passos promovidos e outros ainda compat deve continuar passando durante todo o processo. Nunca torne "totalmente tipado" uma pré-condição para "funciona".
- Não exclua a cola compat antes de seu substituto tipado estar escrito e passando. Escreva o novo passo, faça-o funcionar com
nuka do, só então remova o antigo — nunca a ordem inversa. - Não tente adivinhar uma correção que
nuka checkounuka runnão pediram. A saída deles é a evidência do que está errado; alterar algo que eles não sinalizaram é uma alteração feita por palpite, não no que realmente quebrou.