@softize/opus 22.0.0 → 22.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/bin/cli.mjs +9 -1
- package/bin/lib/check.mjs +6 -1
- package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
- package/package.json +5 -1
- package/registry/skills/create-opus-action/references/contract-and-binding.md +5 -3
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +0 -4
- package/registry/skills/test-opus-action/SKILL.md +2 -2
- package/registry/skills/test-opus-action/references/harness.md +2 -2
- package/registry/templates/app/src/App.tsx +1 -1
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
- package/registry/templates/app/src/domains/tasks/actions/list.ts +3 -5
- package/registry/templates/app/vite.config.ts +3 -2
- package/src/core/domain.ts +2 -2
- package/src/core/types.ts +6 -20
- package/src/design/index.ts +179 -0
- package/src/testing/fake.ts +2 -2
- package/src/testing/index.ts +7 -3
- package/src/ui/docs/content/actions.md +49 -43
- package/src/ui/docs/content/testing.md +9 -25
- package/src/vite/design.ts +133 -23
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 22.1.0 — 2026-09-28
|
|
11
|
+
|
|
12
|
+
Os dados do modo design saem do contrato (ADR 0037). No modo design, toda action passa a responder
|
|
13
|
+
sem que o contrato declare nada: a resposta é gerada pelo `fake` a partir do `output`, a mesma
|
|
14
|
+
requisição recebe a mesma resposta, uma `list` devolve três páginas do tamanho pedido em `limit` (20
|
|
15
|
+
por padrão) pela paginação declarada, e a saída repete o `id` da entrada quando o schema aceita.
|
|
16
|
+
Quando o design precisa de coerência, um cenário escrito com `defineDesignScenario`, do novo
|
|
17
|
+
`@softize/opus/design`, responde no lugar da resposta gerada. O plugin encontra os cenários pela
|
|
18
|
+
opção `scenarios` de `opusDesign()`, com caminhos ou globs; um cenário de action inexistente, ou
|
|
19
|
+
dois cenários para a mesma action, impedem o runtime de design de subir, e o erro lista os nomes.
|
|
20
|
+
|
|
21
|
+
`mockHandler` fica obsoleto. Ele ainda responde no modo design, abaixo do cenário e acima da
|
|
22
|
+
resposta gerada, e sai na 23.0.0. Os tipos o marcam com `@deprecated`. O `opus check` passa a
|
|
23
|
+
contar, numa linha de aviso que não reprova, quantas actions ainda o declaram. A sonda
|
|
24
|
+
`/__opus/design` ganha `scenarios` e `generated`; `mocked` e `unmocked` mantêm o formato anterior.
|
|
25
|
+
|
|
26
|
+
O template de `opus create` e as skills `create-opus-action` e `test-opus-action` deixam de ensinar
|
|
27
|
+
`mockHandler`.
|
|
28
|
+
|
|
29
|
+
**O que fazer:** nada é obrigatório nesta versão. Para migrar, apague os `mockHandler` dos
|
|
30
|
+
contratos; se a resposta de algum importar para o design, transforme-a em cenário e aponte a opção
|
|
31
|
+
`scenarios` do plugin para o lugar onde os cenários ficam. Testes que usavam `runAction` com
|
|
32
|
+
`OPUS_MODE=design` para exercitar o mock devem testar a action ligada ao handler.
|
|
33
|
+
|
|
10
34
|
## 22.0.0 — 2026-09-28
|
|
11
35
|
|
|
12
36
|
As superfícies passam a usar uma introdução única e ganham composições explícitas para conteúdo
|
package/bin/cli.mjs
CHANGED
|
@@ -40,7 +40,15 @@ async function cmdCheck(dir) {
|
|
|
40
40
|
for (const error of materialization.errors) log('error', `✗ opus check: ${error}`)
|
|
41
41
|
process.exit(1)
|
|
42
42
|
}
|
|
43
|
-
const { files, actions, contracts, findings, hasMarker } = await scanDir(root)
|
|
43
|
+
const { files, actions, contracts, deprecatedMockHandlers, findings, hasMarker } = await scanDir(root)
|
|
44
|
+
// Aviso de migração, não violação: uma linha só, para não poluir o gate a cada execução.
|
|
45
|
+
if (deprecatedMockHandlers > 0) {
|
|
46
|
+
log(
|
|
47
|
+
'warn',
|
|
48
|
+
`⚠ opus check: ${deprecatedMockHandlers} action(s) ainda declaram \`mockHandler\`, que sai na próxima major. ` +
|
|
49
|
+
'No modo design, a action sem mock já responde com dado gerado; use `defineDesignScenario` quando o design precisar de um cenário (ADR 0037).',
|
|
50
|
+
)
|
|
51
|
+
}
|
|
44
52
|
|
|
45
53
|
if (actions === 0 && findings.length === 0) {
|
|
46
54
|
const verdict = emptyScanVerdict({ hasMarker, contracts })
|
package/bin/lib/check.mjs
CHANGED
|
@@ -642,6 +642,8 @@ export function checkProject(sources) {
|
|
|
642
642
|
const productDeclarations = [];
|
|
643
643
|
const entityNames = new Set();
|
|
644
644
|
const actionNames = new Set();
|
|
645
|
+
// `mockHandler` obsoleto (ADR 0037): contado, não reprovado — a minor ainda o aceita.
|
|
646
|
+
let deprecatedMockHandlers = 0;
|
|
645
647
|
|
|
646
648
|
for (const { file, text } of sources) {
|
|
647
649
|
const declarations = parseDataProducts(file, text);
|
|
@@ -666,6 +668,7 @@ export function checkProject(sources) {
|
|
|
666
668
|
if (item.name !== null) actionNames.add(item.name);
|
|
667
669
|
}
|
|
668
670
|
for (const f of lintAction(item)) findings.push({ ...f, file });
|
|
671
|
+
if (item.keys.includes("mockHandler")) deprecatedMockHandlers += 1;
|
|
669
672
|
}
|
|
670
673
|
}
|
|
671
674
|
|
|
@@ -771,6 +774,7 @@ export function checkProject(sources) {
|
|
|
771
774
|
actions,
|
|
772
775
|
contracts,
|
|
773
776
|
dataProducts: productDeclarations.length,
|
|
777
|
+
deprecatedMockHandlers,
|
|
774
778
|
findings,
|
|
775
779
|
};
|
|
776
780
|
}
|
|
@@ -1921,7 +1925,7 @@ async function walkUiFiles(dir, acc = []) {
|
|
|
1921
1925
|
return acc;
|
|
1922
1926
|
}
|
|
1923
1927
|
|
|
1924
|
-
/** Escaneia um diretório → { files, actions, contracts, findings, hasMarker }.
|
|
1928
|
+
/** Escaneia um diretório → { files, actions, contracts, deprecatedMockHandlers, findings, hasMarker }.
|
|
1925
1929
|
* `hasMarker` = o diretório é um projeto opus (tem opus.json na raiz). */
|
|
1926
1930
|
export async function scanDir(rootDir) {
|
|
1927
1931
|
rootDir = canonicalProjectDirectory(rootDir);
|
|
@@ -1951,6 +1955,7 @@ export async function scanDir(rootDir) {
|
|
|
1951
1955
|
actions: checked.actions,
|
|
1952
1956
|
contracts: checked.contracts,
|
|
1953
1957
|
dataProducts: checked.dataProducts,
|
|
1958
|
+
deprecatedMockHandlers: checked.deprecatedMockHandlers,
|
|
1954
1959
|
findings: [...checked.findings, ...uiFindings],
|
|
1955
1960
|
hasMarker,
|
|
1956
1961
|
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# ADR 0037 — Dados do modo design ficam fora do contrato
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita; a primeira etapa saiu na 22.1.0, e a remoção fica para a 23.0.0.
|
|
4
|
+
- **Data:** 2026-09-28.
|
|
5
|
+
- **Relacionadas:** ADR 0002 (seeds estruturados).
|
|
6
|
+
|
|
7
|
+
## Contexto
|
|
8
|
+
|
|
9
|
+
O modo design serve para desenhar e revisar telas sem backend: `opusDesign()` monta o runtime
|
|
10
|
+
dentro do dev server, e cada action responde pelo `mockHandler` declarado no contrato. O preview de
|
|
11
|
+
sessão do Maestro depende desse modo e não tem outro propósito além de design.
|
|
12
|
+
|
|
13
|
+
Em setembro de 2026, o único consumidor, a Grand Brasil, tinha 233 `mockHandler` para 256
|
|
14
|
+
contratos, cerca de 3.200 linhas entre handlers, fixtures e estado simulado. A medição mostrou três
|
|
15
|
+
problemas:
|
|
16
|
+
|
|
17
|
+
- **O mock viaja com o contrato.** O contrato é a fatia compartilhada com a interface, então o
|
|
18
|
+
build de produção da aplicação leva os handlers simulados e suas fixtures. Um build de produção
|
|
19
|
+
da Grand Brasil continha textos das fixtures e os próprios handlers.
|
|
20
|
+
- **Metade dos mocks não acrescenta nada ao schema.** Cerca de 110 handlers devolvem um literal
|
|
21
|
+
fixo (`{ id: 'mock-x' }`) ou uma lista vazia, o que o `fake` já gera a partir do `output`.
|
|
22
|
+
- **A outra metade replica o domínio.** Cerca de 95 handlers simulam um CRUD em memória e
|
|
23
|
+
reimplementam regras do handler real, como o significado de cada filtro operacional de uma
|
|
24
|
+
listagem. Em setembro, 19 commits de funcionalidade tiveram de alterar mocks junto com o código
|
|
25
|
+
real, e nada garante que as duas versões da regra continuem iguais.
|
|
26
|
+
|
|
27
|
+
Forças em tensão:
|
|
28
|
+
|
|
29
|
+
- a aplicação real deve carregar o mínimo possível de algo que só existe para design;
|
|
30
|
+
- o preview precisa responder a qualquer action, inclusive às criadas durante a sessão;
|
|
31
|
+
- às vezes o design precisa de coerência entre respostas, como um item em cada situação ou algo
|
|
32
|
+
criado que aparece na lista em seguida;
|
|
33
|
+
- no preview, variar entre sessões é aceitável, porque dado de design não é evidência. Os seeds da
|
|
34
|
+
ADR 0002 são evidência e continuam determinísticos;
|
|
35
|
+
- o Opus é protocolo e SDK: onde os cenários ficam e se são versionados é escolha do projeto.
|
|
36
|
+
|
|
37
|
+
## Decisão
|
|
38
|
+
|
|
39
|
+
1. **O contrato deixa de declarar dados de design.** `mockHandler` sai de `ActionBase`, do
|
|
40
|
+
contrato, do `ActionDef` e do `runAction`. O runtime de produção não lê mais `OPUS_MODE` nem
|
|
41
|
+
tem ramo de design. Toda a lógica de design fica em `@softize/opus/vite`.
|
|
42
|
+
2. **Toda action responde por padrão no modo design.** O plugin registra cada contrato com um
|
|
43
|
+
executor de design que, na ausência de cenário, devolve dado gerado pelo `fake` a partir do
|
|
44
|
+
`output`:
|
|
45
|
+
- `list` devolve três páginas geradas por `fakeMany`, com `items`, `cursor` e `total`, do
|
|
46
|
+
tamanho pedido em `limit` (20 quando a entrada não pede, até 100), e respeita a paginação
|
|
47
|
+
declarada. Busca e filtros não filtram o dado gerado, porque sobre dado
|
|
48
|
+
sorteado o resultado não mostraria nada útil; quando o filtro importar para o design, o
|
|
49
|
+
caminho é um cenário;
|
|
50
|
+
- a semente é derivada do nome da action e da entrada canônica, então a mesma requisição recebe
|
|
51
|
+
a mesma resposta;
|
|
52
|
+
- quando a entrada e a saída têm `id`, a saída ecoa o `id` da entrada, desde que o schema da
|
|
53
|
+
saída aceite esse valor, para que abrir um item listado mostre o mesmo identificador;
|
|
54
|
+
- uma action ligada a um handler real (`bindAction`) mantém esse handler, como hoje.
|
|
55
|
+
3. **Cenários são registrados fora do contrato, pelo nome da action.** O plugin ganha a opção
|
|
56
|
+
`scenarios`, com um ou mais globs. Os módulos casados exportam cenários criados por
|
|
57
|
+
`defineDesignScenario(contrato, resposta)`, e a resposta pode ser um valor ou uma função tipada
|
|
58
|
+
pela entrada e pela saída do contrato. O cenário vence a resposta padrão. Um cenário para uma
|
|
59
|
+
action inexistente é erro visível no preview, não um silêncio.
|
|
60
|
+
4. **O Opus não decide onde os cenários ficam.** A opção não tem default dentro do código da
|
|
61
|
+
aplicação. O consumidor escolhe, por exemplo, uma pasta ignorada pelo git que o agente da sessão
|
|
62
|
+
preenche sob demanda. Cenários começam descartáveis: só vale discutir versionar um cenário no
|
|
63
|
+
repositório do consumidor quando o mesmo for recriado em sessões diferentes.
|
|
64
|
+
5. **Stubs de rotas externas continuam como estão.** A opção `stubs` segue atendendo superfícies
|
|
65
|
+
fora do protocolo de actions, como autenticação e motores externos.
|
|
66
|
+
|
|
67
|
+
## Consequências
|
|
68
|
+
|
|
69
|
+
- A aplicação deixa de carregar mocks. O contrato volta a descrever só a capacidade, e o build de
|
|
70
|
+
produção não inclui dados de design.
|
|
71
|
+
- Uma action nova aparece no preview sem nenhuma linha de mock.
|
|
72
|
+
- O dado padrão tem o formato certo, mas não a regra de negócio: valores de enum são sorteados, e
|
|
73
|
+
campos de uma listagem e de um detalhe só coincidem no `id`. Quando isso atrapalhar o design, o
|
|
74
|
+
caminho é um cenário.
|
|
75
|
+
- Sem cenário, não há estado entre chamadas: criar um item não o faz aparecer na lista.
|
|
76
|
+
- É uma versão major. Quem declara `mockHandler` precisa remover a declaração ou transformá-la em
|
|
77
|
+
cenário, e testes que usam `OPUS_MODE=design` com `runAction` precisam de outra forma de
|
|
78
|
+
exercitar o contrato.
|
|
79
|
+
- O template de `opus create`, a documentação "Prototipar com dados simulados", as skills
|
|
80
|
+
`create-opus-action` e `test-opus-action` e o gate de ordem de campos (`check.mjs`) mudam junto.
|
|
81
|
+
- No Maestro, que fica fora deste pacote, o preview continua igual. O agente da sessão passa a
|
|
82
|
+
escrever cenários quando precisar, orientado por uma skill própria.
|
|
83
|
+
|
|
84
|
+
## Alternativas descartadas
|
|
85
|
+
|
|
86
|
+
- **Manter o estado atual.** Preserva cenários prontos entre sessões, mas mantém o mock no bundle,
|
|
87
|
+
a regra de negócio duplicada e o custo de cada funcionalidade nova.
|
|
88
|
+
- **Remover `mockHandler` apenas no build de produção.** Resolve o bundle e nada mais. O contrato
|
|
89
|
+
continua misturando capacidade e simulação, e a duplicação de regra permanece.
|
|
90
|
+
- **Preview com backend real e dados dos seeds.** Mostra o comportamento verdadeiro e elimina os
|
|
91
|
+
mocks, mas exige banco, API e isolamento por sessão num preview que só serve a design, além de
|
|
92
|
+
rever as decisões do Maestro de manter o preview isolado e sem Postgres.
|
|
93
|
+
- **CRUD em memória genérico a partir de metadado novo no contrato.** Exigiria declarar entidade e
|
|
94
|
+
operação em cada action e, mesmo assim, não cobriria regras de negócio, que continuariam
|
|
95
|
+
escritas à mão.
|
|
96
|
+
- **Somente a resposta padrão, sem cenários.** É o mínimo absoluto, mas deixaria o design sem saída
|
|
97
|
+
quando a coerência importa. O custo do mecanismo de cenários é pequeno perto disso.
|
|
98
|
+
|
|
99
|
+
## Transição
|
|
100
|
+
|
|
101
|
+
A saída acontece em duas versões. A 22.1.0 adiciona a resposta padrão e os cenários, com
|
|
102
|
+
`mockHandler` ainda aceito e precedendo a resposta gerada, e o `check` passa a contar o campo como
|
|
103
|
+
obsoleto. O consumidor remove seus mocks domínio a domínio. A 23.0.0 remove o campo e o ramo de
|
|
104
|
+
design do runtime.
|
|
105
|
+
|
|
106
|
+
## Verificação
|
|
107
|
+
|
|
108
|
+
- testes do plugin: resposta padrão por `kind` (`view`, `list`, `form`, `simple`), páginas do
|
|
109
|
+
tamanho pedido em `limit` com a paginação declarada, mesma resposta para a mesma entrada, eco do
|
|
110
|
+
`id`, precedência do cenário e erro para cenário sem action ou duplicado;
|
|
111
|
+
- teste de tipos: `defineDesignScenario` infere entrada e saída do contrato e rejeita resposta
|
|
112
|
+
incompatível;
|
|
113
|
+
- busca estática: nenhum `OPUS_MODE` nem `mockHandler` em `src/core` e `src/testing` depois da
|
|
114
|
+
major;
|
|
115
|
+
- no consumidor: nenhum `mockHandler` nos contratos, e o build de produção sem textos das fixtures
|
|
116
|
+
antigas;
|
|
117
|
+
- typecheck, suíte do pacote, `opus check` e os gates de materialização verdes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softize/opus",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.1.0",
|
|
4
4
|
"description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -173,6 +173,10 @@
|
|
|
173
173
|
"types": "./src/seed/index.ts",
|
|
174
174
|
"default": "./src/seed/index.ts"
|
|
175
175
|
},
|
|
176
|
+
"./design": {
|
|
177
|
+
"types": "./src/design/index.ts",
|
|
178
|
+
"default": "./src/design/index.ts"
|
|
179
|
+
},
|
|
176
180
|
"./presentation": {
|
|
177
181
|
"types": "./src/presentation/index.ts",
|
|
178
182
|
"default": "./src/presentation/index.ts"
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Contrato e binding
|
|
2
2
|
|
|
3
|
-
O contrato contém `name`, `kind`, documentação, schemas, metadata de UI
|
|
4
|
-
declarativa compartilhável
|
|
3
|
+
O contrato contém `name`, `kind`, documentação, schemas, metadata de UI e autorização
|
|
4
|
+
declarativa compartilhável. Ele não declara dados de design: no modo design, a action sem mock já
|
|
5
|
+
responde com dado gerado a partir do `output`, e um cenário específico, quando necessário, fica
|
|
6
|
+
fora do contrato em `defineDesignScenario` (ADR 0037). `mockHandler` está obsoleto.
|
|
5
7
|
|
|
6
8
|
O binding contém `handler`, `loads`, autorização dependente de dados carregados, execução
|
|
7
9
|
em background, eventos e idempotência. `bindAction(contract, binding)` produz o `ActionDef`
|
|
8
10
|
registrado no runtime.
|
|
9
11
|
|
|
10
12
|
Ordem cobrada pelo check: identidade → documentação → input/output → autorização →
|
|
11
|
-
handler
|
|
13
|
+
handler → comportamento. Campos específicos do kind permanecem junto do grupo ao qual
|
|
12
14
|
servem e o scaffold acompanha a forma atual do SDK.
|
|
@@ -18,7 +18,6 @@ ${common}
|
|
|
18
18
|
input: z.object({}),
|
|
19
19
|
output: z.object({ id: z.string() }),
|
|
20
20
|
paginate: 'cursor',
|
|
21
|
-
mockHandler: async () => ({ items: [], cursor: { next: null }, total: 0 }),
|
|
22
21
|
})`
|
|
23
22
|
}
|
|
24
23
|
if (kind === 'form') {
|
|
@@ -30,7 +29,6 @@ ${common}
|
|
|
30
29
|
input: z.object({ id: z.string() }),
|
|
31
30
|
output: z.object({ id: z.string() }),
|
|
32
31
|
fields: { id: { label: 'Identificador' } },
|
|
33
|
-
mockHandler: async (_ctx, input) => ({ id: input.id }),
|
|
34
32
|
})`
|
|
35
33
|
}
|
|
36
34
|
if (kind === 'view') {
|
|
@@ -40,7 +38,6 @@ ${common}
|
|
|
40
38
|
description: 'Descreva o comportamento de negócio e seus limites.',
|
|
41
39
|
input: z.object({ id: z.string() }),
|
|
42
40
|
output: z.object({ id: z.string() }),
|
|
43
|
-
mockHandler: async (_ctx, input) => ({ id: input.id }),
|
|
44
41
|
})`
|
|
45
42
|
}
|
|
46
43
|
return `export const ${identifier} = defineContract({
|
|
@@ -50,7 +47,6 @@ ${common}
|
|
|
50
47
|
messages: { success: 'Operação concluída.', error: 'Não foi possível concluir.' },
|
|
51
48
|
input: z.object({ id: z.string() }),
|
|
52
49
|
output: z.object({ ok: z.boolean() }),
|
|
53
|
-
mockHandler: async () => ({ ok: true }),
|
|
54
50
|
})`
|
|
55
51
|
}
|
|
56
52
|
|
|
@@ -12,8 +12,8 @@ infraestrutura real quando a unidade é o contrato.
|
|
|
12
12
|
|
|
13
13
|
## Procedimento
|
|
14
14
|
|
|
15
|
-
1. Importar o `ActionDef` produzido pelo binding
|
|
16
|
-
|
|
15
|
+
1. Importar o `ActionDef` produzido pelo binding. Dados do modo design não são comportamento da
|
|
16
|
+
action e não entram nestes testes.
|
|
17
17
|
2. Executar com `runAction` e fornecer `user`, `can`, `loaded`, storage ou outros adapters
|
|
18
18
|
pelas opções do harness.
|
|
19
19
|
3. Cobrir sucesso, input inválido, autorização negada e erros de negócio materiais.
|
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
- `memStorage()`: adapter de storage em memória com inspeção de chaves.
|
|
7
7
|
- `fake` e `fakeMany`: dados determinísticos derivados de schema.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
`
|
|
9
|
+
Teste o binding completo. Um contrato sem handler falha com `testing.no_handler`: os dados do modo
|
|
10
|
+
design são gerados pelo plugin `opusDesign()` e não são comportamento da action.
|
|
@@ -9,7 +9,7 @@ type TaskItem = z.infer<typeof Task>
|
|
|
9
9
|
export function App(): React.ReactElement {
|
|
10
10
|
// O front consome o CONTRATO via /api — o plugin opusDesign (vite.config.ts)
|
|
11
11
|
// serve o backend no próprio dev server: dev normal executa o handler real;
|
|
12
|
-
// `pnpm dev:design` responde com
|
|
12
|
+
// `pnpm dev:design` responde com dado gerado a partir do contrato, isolado de qualquer backend.
|
|
13
13
|
const { items, isLoading, error, refetch } = useListAction<TaskItem>(taskList)
|
|
14
14
|
|
|
15
15
|
return (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { runAction } from '@softize/opus/testing'
|
|
3
|
-
import {
|
|
3
|
+
import { taskListImpl } from './list.ts'
|
|
4
4
|
|
|
5
5
|
// O harness roda o MESMO pipeline do runtime (valida input, cobra auth, valida
|
|
6
6
|
// output) — teste pelo contrato, não chamando o handler à mão.
|
|
@@ -14,21 +14,4 @@ describe('task.list', () => {
|
|
|
14
14
|
it('sem usuário, barra: a action não é pública', async () => {
|
|
15
15
|
await expect(runAction(taskListImpl, {})).rejects.toMatchObject({ code: 'auth.unauthenticated' })
|
|
16
16
|
})
|
|
17
|
-
|
|
18
|
-
describe('modo design (o que o `pnpm dev:design` serve)', () => {
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
// OPUS_MODE é global do processo — limpa pra não vazar entre testes.
|
|
21
|
-
delete process.env['OPUS_MODE']
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('o contrato puro responde pelo mockHandler, com dado válido pro schema', async () => {
|
|
25
|
-
process.env['OPUS_MODE'] = 'design'
|
|
26
|
-
const { output } = await runAction(taskList, {}, { user: { id: 'u1' } })
|
|
27
|
-
expect(output.items.length).toBeGreaterThan(0)
|
|
28
|
-
// `list` pula a validação de output no pipeline — afirme item a item.
|
|
29
|
-
for (const item of output.items) {
|
|
30
|
-
expect(Task.safeParse(item).success).toBe(true)
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
17
|
})
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { bindAction, defineContract } from '@softize/opus/core'
|
|
3
|
-
import { fakeMany } from '@softize/opus/testing'
|
|
4
3
|
|
|
5
|
-
/** Item do domínio-exemplo — o schema alimenta contrato,
|
|
4
|
+
/** Item do domínio-exemplo — o schema alimenta contrato, dados de design e testes. */
|
|
6
5
|
export const Task = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
|
|
7
6
|
|
|
8
7
|
// O split canônico contrato↔handler: o CONTRATO é declarativo e roda nos dois
|
|
9
8
|
// lados (a SPA consome via useListAction); o BINDING é server-only. Em
|
|
10
|
-
// `pnpm dev:design` o
|
|
11
|
-
// no dev normal e em prod, o handler real do binding assume.
|
|
9
|
+
// `pnpm dev:design` o plugin responde com dado gerado a partir do `output`
|
|
10
|
+
// (ADR 0037); no dev normal e em prod, o handler real do binding assume.
|
|
12
11
|
export const taskList = defineContract({
|
|
13
12
|
name: 'task.list',
|
|
14
13
|
kind: 'list',
|
|
@@ -19,7 +18,6 @@ export const taskList = defineContract({
|
|
|
19
18
|
input: z.object({}),
|
|
20
19
|
output: Task,
|
|
21
20
|
paginate: 'cursor',
|
|
22
|
-
mockHandler: async () => ({ items: fakeMany(Task, 5), cursor: { next: null }, total: 5 }),
|
|
23
21
|
})
|
|
24
22
|
|
|
25
23
|
// Fixture em memória — o domínio-exemplo não tem banco; troque pelo seu repositório.
|
|
@@ -5,11 +5,12 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Modo design (`vite --mode design`, script dev:design): o runtime Opus sobe DENTRO do
|
|
8
|
-
* dev server e
|
|
8
|
+
* dev server e as actions respondem o `/api` com dado gerado a partir do `output` do contrato
|
|
9
|
+
* (ADR 0037; cenários específicos entram pela opção `scenarios`) — preview autossuficiente,
|
|
9
10
|
* isolado de prod (o plugin desliga o `server.proxy` sozinho; prefixo ex-proxy sem
|
|
10
11
|
* cobertura responde 503, nunca vaza pra porta real). Quando o app ganhar um backend real,
|
|
11
12
|
* o `dev` normal proxeia pra ele (adicione `server.proxy`); o preview de design segue nos
|
|
12
|
-
*
|
|
13
|
+
* dados de design. O entry default do plugin é `./opus.config.ts` (a fonte da spec deste app).
|
|
13
14
|
*
|
|
14
15
|
* Import DINÂMICO NO TOP-LEVEL, de propósito, e em duas condições que andam juntas: (1) o
|
|
15
16
|
* @softize/opus embarca TS source e o config loader padrão (esbuild bundle — o que build e
|
package/src/core/domain.ts
CHANGED
|
@@ -36,7 +36,7 @@ import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
|
|
|
36
36
|
* Um registrável de action num domínio: BOUND (`ActionDef`, com handler) ou
|
|
37
37
|
* contrato PURO (`ActionContract`, sem handler). Um domínio de design — a
|
|
38
38
|
* fatia isomorfa (ADR 0004) — registra só contratos, e em modo design o
|
|
39
|
-
* `
|
|
39
|
+
* plugin `opusDesign()` responde por eles (ADR 0037). Fora de design, contrato sem handler falha no
|
|
40
40
|
* `execute` (comportamento INTENCIONAL); o tipo só deixa de exigir o cast.
|
|
41
41
|
*/
|
|
42
42
|
export type DomainAction = ActionDef | ActionContract
|
|
@@ -205,7 +205,7 @@ function* iterateActions(
|
|
|
205
205
|
): IterableIterator<ActionDef> {
|
|
206
206
|
if (actions === undefined) return
|
|
207
207
|
// Um contrato puro (`ActionContract`, sem handler) entra como `ActionDef` no
|
|
208
|
-
// runtime — em design o `
|
|
208
|
+
// runtime — em design o plugin `opusDesign()` responde; fora de design, a ausência de
|
|
209
209
|
// handler falha no `execute` (intencional). O cast reconcilia o tipo neste
|
|
210
210
|
// boundary de normalização; ver `DomainAction`.
|
|
211
211
|
if (Array.isArray(actions)) {
|
package/src/core/types.ts
CHANGED
|
@@ -821,27 +821,11 @@ export interface ActionBase<In, Out, ParsedIn = In> {
|
|
|
821
821
|
|
|
822
822
|
/**
|
|
823
823
|
* Handler alternativo executado quando `process.env.OPUS_MODE === 'design'`.
|
|
824
|
-
* Use pra prototipar UI com dados realistas sem precisar do backend pronto.
|
|
825
824
|
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
* Em modo default (sem env): `mockHandler` é ignorado. Útil pra apagar
|
|
831
|
-
* em revisão ou manter pro Designer reusar.
|
|
832
|
-
*
|
|
833
|
-
* Pattern recomendado pra ações que só têm mock durante dev:
|
|
834
|
-
*
|
|
835
|
-
* ```ts
|
|
836
|
-
* defineAction({
|
|
837
|
-
* name: 'ticket.list',
|
|
838
|
-
* // ...
|
|
839
|
-
* mockHandler: async () => MOCK_TICKETS,
|
|
840
|
-
* handler: () => {
|
|
841
|
-
* throw new Error('handler real ainda não implementado — use OPUS_MODE=design')
|
|
842
|
-
* },
|
|
843
|
-
* })
|
|
844
|
-
* ```
|
|
825
|
+
* @deprecated Desde a 22.1.0 (ADR 0037); sai na próxima major. No modo design, a action sem
|
|
826
|
+
* mock já responde com dado gerado a partir do `output`; quando o design precisar de uma resposta
|
|
827
|
+
* específica, declare um cenário fora do contrato com `defineDesignScenario`, de
|
|
828
|
+
* `@softize/opus/design`, e aponte a opção `scenarios` do `opusDesign()` para ele.
|
|
845
829
|
*/
|
|
846
830
|
mockHandler?: HandlerFn<ParsedIn, Out>
|
|
847
831
|
|
|
@@ -919,6 +903,8 @@ export interface ListAction<In, Out, ParsedIn = In>
|
|
|
919
903
|
/**
|
|
920
904
|
* Mesma semântica de `ActionBase.mockHandler`, mas devolve `Paginated<Out>`
|
|
921
905
|
* (search retorna lista paginada, não item solto).
|
|
906
|
+
*
|
|
907
|
+
* @deprecated Desde a 22.1.0 (ADR 0037); ver `ActionBase.mockHandler`.
|
|
922
908
|
*/
|
|
923
909
|
mockHandler?: (
|
|
924
910
|
ctx: ActionContext,
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dados do modo design fora do contrato (ADR 0037).
|
|
3
|
+
*
|
|
4
|
+
* No modo design, toda action responde: sem cenário, a resposta é gerada pelo `fake` a partir do
|
|
5
|
+
* `output` do contrato. Quando o design precisa de coerência (um item em cada situação, algo criado
|
|
6
|
+
* que aparece na lista), um cenário registrado pelo nome da action vence a resposta padrão.
|
|
7
|
+
*
|
|
8
|
+
* O módulo é isomórfico e não conhece Vite: o plugin `opusDesign()` carrega os cenários e liga as
|
|
9
|
+
* respostas ao runtime de design.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
import type { ActionContext, InferOutput, Paginated } from '../core/types.ts'
|
|
14
|
+
import { fake, fakeMany } from '../testing/fake.ts'
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Cenários
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
// `Symbol.for`: o plugin roda no processo do Vite e carrega os cenários pelo pipeline SSR, então
|
|
21
|
+
// a marca precisa sobreviver a duas instâncias do módulo.
|
|
22
|
+
const SCENARIO = Symbol.for('@softize/opus/design-scenario')
|
|
23
|
+
|
|
24
|
+
type ContractKind = 'simple' | 'form' | 'list' | 'view'
|
|
25
|
+
|
|
26
|
+
interface ScenarioContract {
|
|
27
|
+
name: string
|
|
28
|
+
kind: ContractKind
|
|
29
|
+
input: unknown
|
|
30
|
+
output: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ScenarioInput<C> = C extends { input: infer S } ? InferOutput<S> : never
|
|
34
|
+
|
|
35
|
+
type ScenarioOutput<C> = C extends { output: infer S }
|
|
36
|
+
? C extends { kind: 'list' }
|
|
37
|
+
? Paginated<InferOutput<S>>
|
|
38
|
+
: InferOutput<S>
|
|
39
|
+
: never
|
|
40
|
+
|
|
41
|
+
/** Resposta de um cenário: o valor devolvido, ou uma função da entrada já validada. */
|
|
42
|
+
export type DesignScenarioResponse<C> =
|
|
43
|
+
| ScenarioOutput<C>
|
|
44
|
+
| ((input: ScenarioInput<C>, ctx: ActionContext) => ScenarioOutput<C> | Promise<ScenarioOutput<C>>)
|
|
45
|
+
|
|
46
|
+
/** Cenário de design ligado a uma action pelo nome. */
|
|
47
|
+
export interface DesignScenario {
|
|
48
|
+
readonly [SCENARIO]: true
|
|
49
|
+
readonly action: string
|
|
50
|
+
respond(input: unknown, ctx: ActionContext): Promise<unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Declara a resposta de uma action no modo design, fora do contrato. A resposta é tipada pela
|
|
55
|
+
* entrada e pela saída do contrato; em `list`, é a página completa (`items`, `cursor`, `total`).
|
|
56
|
+
*/
|
|
57
|
+
export function defineDesignScenario<C extends ScenarioContract>(
|
|
58
|
+
contract: C,
|
|
59
|
+
// NoInfer: o contrato é inferido só do primeiro argumento. Sem isso, a resposta entra na
|
|
60
|
+
// inferência e os literais do retorno de uma função alargam (`'closed'` vira `string`).
|
|
61
|
+
response: NoInfer<DesignScenarioResponse<C>>,
|
|
62
|
+
): DesignScenario {
|
|
63
|
+
const respond =
|
|
64
|
+
typeof response === 'function'
|
|
65
|
+
? async (input: unknown, ctx: ActionContext): Promise<unknown> =>
|
|
66
|
+
(response as (input: unknown, ctx: ActionContext) => unknown)(input, ctx)
|
|
67
|
+
: async (): Promise<unknown> => response
|
|
68
|
+
return { [SCENARIO]: true, action: contract.name, respond }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `true` quando o valor foi criado por `defineDesignScenario`. */
|
|
72
|
+
export function isDesignScenario(value: unknown): value is DesignScenario {
|
|
73
|
+
return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[SCENARIO] === true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// Resposta padrão
|
|
78
|
+
// =============================================================================
|
|
79
|
+
|
|
80
|
+
/** Itens por página da listagem padrão quando a entrada não pede `limit`. */
|
|
81
|
+
export const DESIGN_PAGE_SIZE = 20
|
|
82
|
+
|
|
83
|
+
// Teto do `limit` atendido: o maior tamanho de página que o `ActionList` oferece.
|
|
84
|
+
const DESIGN_MAX_PAGE_SIZE = 100
|
|
85
|
+
|
|
86
|
+
// Três páginas: o bastante para a paginação do preview ter para onde ir.
|
|
87
|
+
const DESIGN_PAGES = 3
|
|
88
|
+
|
|
89
|
+
const PAGINATION_KEYS = new Set(['page', 'cursor', 'offset', 'limit'])
|
|
90
|
+
|
|
91
|
+
interface DesignActionShape {
|
|
92
|
+
name: string
|
|
93
|
+
kind: ContractKind
|
|
94
|
+
output?: unknown
|
|
95
|
+
paginate?: 'cursor' | 'offset' | false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resposta padrão do modo design: dado gerado pelo `fake` a partir do `output`, com semente
|
|
100
|
+
* derivada do nome da action e da entrada. A mesma requisição recebe a mesma resposta.
|
|
101
|
+
*
|
|
102
|
+
* - `list` devolve três páginas do tamanho pedido em `limit` ({@link DESIGN_PAGE_SIZE} quando a
|
|
103
|
+
* entrada não pede) e respeita a paginação declarada; busca e filtros não filtram o dado gerado.
|
|
104
|
+
* - Nas demais, a saída ecoa o `id` da entrada quando o schema da saída aceita esse valor.
|
|
105
|
+
* - Saída que não é schema zod não tem gerador: a resposta é `null`.
|
|
106
|
+
*/
|
|
107
|
+
export function designResponse(action: DesignActionShape, input: unknown): unknown {
|
|
108
|
+
const output = action.output
|
|
109
|
+
if (!(output instanceof z.ZodType)) return null
|
|
110
|
+
const seed = `${action.name}:${canonicalJson(withoutPagination(input))}`
|
|
111
|
+
|
|
112
|
+
if (action.kind === 'list') {
|
|
113
|
+
const paginate = action.paginate ?? 'cursor'
|
|
114
|
+
const size = pageSize(input)
|
|
115
|
+
if (paginate === false) {
|
|
116
|
+
return { items: fakeMany(output, size, { seed }), cursor: { next: null }, total: size }
|
|
117
|
+
}
|
|
118
|
+
// O `limit` fica fora da semente: mudar o tamanho da página redistribui os mesmos itens.
|
|
119
|
+
const all = fakeMany(output, size * DESIGN_PAGES, { seed })
|
|
120
|
+
const page = requestedPage(input, size)
|
|
121
|
+
const start = (page - 1) * size
|
|
122
|
+
const next = paginate === 'cursor' && page < DESIGN_PAGES ? `design:${page + 1}` : null
|
|
123
|
+
const prev = paginate === 'cursor' && page > 1 ? `design:${page - 1}` : null
|
|
124
|
+
return {
|
|
125
|
+
items: all.slice(start, start + size),
|
|
126
|
+
cursor: { next, prev },
|
|
127
|
+
total: all.length,
|
|
128
|
+
} satisfies Paginated<unknown>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const value = fake(output, { seed })
|
|
132
|
+
const inputId = isRecord(input) ? input.id : undefined
|
|
133
|
+
if (inputId !== undefined && isRecord(value) && 'id' in value) {
|
|
134
|
+
// Só ecoa quando a saída continua válida: `id` numérico ou uuid na saída não aceita qualquer
|
|
135
|
+
// valor da entrada, e a validação de output do runtime recusaria a resposta.
|
|
136
|
+
const echoed = { ...value, id: inputId }
|
|
137
|
+
if (output.safeParse(echoed).success) return echoed
|
|
138
|
+
}
|
|
139
|
+
return value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pageSize(input: unknown): number {
|
|
143
|
+
const limit = isRecord(input) && typeof input.limit === 'number' ? Math.trunc(input.limit) : DESIGN_PAGE_SIZE
|
|
144
|
+
return Math.min(Math.max(1, limit), DESIGN_MAX_PAGE_SIZE)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Página pedida, de 1 em diante; além da última página, a lista vem vazia. */
|
|
148
|
+
function requestedPage(input: unknown, size: number): number {
|
|
149
|
+
if (!isRecord(input)) return 1
|
|
150
|
+
const cursor = typeof input.cursor === 'string' ? /^design:(\d+)$/.exec(input.cursor) : null
|
|
151
|
+
const fromCursor = cursor === null ? undefined : Number(cursor[1])
|
|
152
|
+
const fromOffset = typeof input.offset === 'number' ? Math.floor(input.offset / size) + 1 : undefined
|
|
153
|
+
const fromPage = typeof input.page === 'number' ? input.page : undefined
|
|
154
|
+
return Math.max(1, Math.trunc(fromCursor ?? fromPage ?? fromOffset ?? 1))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function withoutPagination(input: unknown): unknown {
|
|
158
|
+
if (!isRecord(input)) return input
|
|
159
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => !PAGINATION_KEYS.has(key)))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** JSON com chaves ordenadas: a mesma entrada produz a mesma semente, qualquer que seja a ordem. */
|
|
163
|
+
function canonicalJson(value: unknown): string {
|
|
164
|
+
if (typeof value === 'bigint') return `${value}n`
|
|
165
|
+
if (value instanceof Date) return JSON.stringify(value.toISOString())
|
|
166
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
167
|
+
if (isRecord(value)) {
|
|
168
|
+
const entries = Object.keys(value)
|
|
169
|
+
.filter((key) => value[key] !== undefined)
|
|
170
|
+
.sort()
|
|
171
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
172
|
+
return `{${entries.join(',')}}`
|
|
173
|
+
}
|
|
174
|
+
return JSON.stringify(value) ?? 'null'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
178
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
179
|
+
}
|
package/src/testing/fake.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `fake` — dado sintético DETERMINÍSTICO a partir de um schema zod. É a matéria-prima do
|
|
3
|
-
* modo design:
|
|
4
|
-
* a UI com dado realista, sem backend nem banco
|
|
3
|
+
* modo design: a resposta gerada pelo plugin `opusDesign()` (ADR 0037) sai de `fake`/`fakeMany`
|
|
4
|
+
* sobre o `output` do contrato, e o preview mostra a UI com dado realista, sem backend nem banco.
|
|
5
5
|
*
|
|
6
6
|
* DETERMINISMO: PRNG próprio semeado (mulberry32) — mesma `seed` → mesma saída, sempre. Nunca
|
|
7
7
|
* `Math.random` (o preview tem que ser reproduzível entre execuções). O default é semeado por
|
package/src/testing/index.ts
CHANGED
|
@@ -206,9 +206,13 @@ export interface TestableAction<In, Out, ParsedIn = In> {
|
|
|
206
206
|
authorize?: AuthorizeSpec<ParsedIn>
|
|
207
207
|
input: Schema<In, ParsedIn>
|
|
208
208
|
output: Schema<Out> | Schema<unknown, Out>
|
|
209
|
-
/** Opcional: contrato puro (sem handler) executa
|
|
209
|
+
/** Opcional: contrato puro (sem handler) só executa pelo `mockHandler` obsoleto. */
|
|
210
210
|
handler?: HandlerFn<ParsedIn, Out>
|
|
211
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime.
|
|
213
|
+
*
|
|
214
|
+
* @deprecated Desde a 22.1.0 (ADR 0037); teste a action ligada ao handler.
|
|
215
|
+
*/
|
|
212
216
|
mockHandler?: HandlerFn<ParsedIn, Out>
|
|
213
217
|
}
|
|
214
218
|
|
|
@@ -329,7 +333,7 @@ export async function runAction<In, Out, ParsedIn = In>(
|
|
|
329
333
|
throw error({
|
|
330
334
|
code: 'testing.no_handler',
|
|
331
335
|
category: 'internal',
|
|
332
|
-
message: `Action "${action.name}" não tem handler executável
|
|
336
|
+
message: `Action "${action.name}" não tem handler executável: teste o ActionDef produzido pelo bindAction. Os dados do modo design são gerados pelo plugin opusDesign() e não são comportamento da action.`,
|
|
333
337
|
})
|
|
334
338
|
}
|
|
335
339
|
const raw = await handlerToRun(ctx, parsed, loaded)
|
|
@@ -152,54 +152,62 @@ A autorização é fechada por padrão: sem `public: true`, a action exige usuá
|
|
|
152
152
|
|
|
153
153
|
## Prototipar com dados simulados
|
|
154
154
|
|
|
155
|
-
Para desenhar a interface antes de o backend existir,
|
|
156
|
-
|
|
157
|
-
`
|
|
158
|
-
handler real, e o mock não precisa acessar `ctx.db`.
|
|
155
|
+
Para desenhar a interface antes de o backend existir, rode o app em modo design. Nesse modo, toda
|
|
156
|
+
action responde sem que o contrato declare nada: a resposta é gerada pelo `fake` a partir do
|
|
157
|
+
`output` do contrato (ADR 0037).
|
|
159
158
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
159
|
+
- A mesma requisição recebe sempre a mesma resposta.
|
|
160
|
+
- Uma `list` devolve três páginas do tamanho pedido em `limit` (20 quando a entrada não pede, até
|
|
161
|
+
100) e respeita a paginação declarada. Busca e filtros não filtram o dado gerado.
|
|
162
|
+
- Quando a entrada e a saída têm `id`, a saída repete o `id` da entrada, desde que o schema da saída
|
|
163
|
+
aceite esse valor. Assim, abrir um item da lista mostra o mesmo identificador.
|
|
164
|
+
|
|
165
|
+
O dado gerado tem o formato certo, mas não segue a regra de negócio: os valores de um enum são
|
|
166
|
+
sorteados, e uma listagem e um detalhe só coincidem no `id`. Quando o design precisa de coerência,
|
|
167
|
+
como um item em cada situação ou algo criado que aparece na lista, escreva um cenário. Ele fica fora
|
|
168
|
+
do contrato e é ligado à action pelo nome:
|
|
163
169
|
|
|
164
170
|
```ts
|
|
165
|
-
import {
|
|
166
|
-
import {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
import { defineDesignScenario } from '@softize/opus/design'
|
|
172
|
+
import { eventGet, eventList } from '../domains/event/contract.ts'
|
|
173
|
+
|
|
174
|
+
// Valor fixo: o caso mais comum.
|
|
175
|
+
export const eventsInEachState = defineDesignScenario(eventList, {
|
|
176
|
+
items: [
|
|
177
|
+
{ id: 'e1', title: 'Revisão agendada', status: 'scheduled' },
|
|
178
|
+
{ id: 'e2', title: 'Revisão perdida', status: 'lost' },
|
|
179
|
+
],
|
|
180
|
+
cursor: { next: null },
|
|
181
|
+
total: 2,
|
|
182
|
+
})
|
|
170
183
|
|
|
171
|
-
//
|
|
172
|
-
const
|
|
184
|
+
// Função: quando a resposta depende da entrada.
|
|
185
|
+
export const eventDetail = defineDesignScenario(eventGet, (input) => ({
|
|
186
|
+
id: input.id,
|
|
187
|
+
title: 'Revisão agendada',
|
|
188
|
+
status: 'scheduled',
|
|
189
|
+
}))
|
|
190
|
+
```
|
|
173
191
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
mockHandler: () => fake(eventRowSchema),
|
|
180
|
-
})
|
|
192
|
+
A resposta é tipada pela entrada e pela saída do contrato; em `list`, é a página inteira. Informe ao
|
|
193
|
+
plugin onde os cenários estão, com caminhos ou globs:
|
|
194
|
+
`opusDesign({ scenarios: 'design/scenarios/*.ts' })`. O cenário vence a resposta gerada. Um cenário
|
|
195
|
+
de uma action que não existe, ou dois cenários para a mesma action, impedem o runtime de design de
|
|
196
|
+
subir, e o erro lista os nomes.
|
|
181
197
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
input: z.object({}),
|
|
186
|
-
output: eventRowSchema,
|
|
187
|
-
paginate: 'cursor',
|
|
188
|
-
mockHandler: async () => ({ items: fakeMany(eventRowSchema, 20), cursor: { next: null }, total: 20 }),
|
|
189
|
-
})
|
|
198
|
+
O Opus não decide onde os cenários ficam nem se entram no repositório. Eles não fazem parte da
|
|
199
|
+
aplicação: podem ficar numa pasta ignorada pelo git e ser escritos durante a sessão de design,
|
|
200
|
+
quando forem necessários.
|
|
190
201
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
})
|
|
196
|
-
```
|
|
202
|
+
`mockHandler` está obsoleto desde a 22.1.0 e sai na próxima major. Enquanto existir, ele ainda
|
|
203
|
+
responde no modo design, abaixo do cenário e acima da resposta gerada, e o `opus check` conta
|
|
204
|
+
quantas actions ainda o declaram. Para migrar, apague o `mockHandler`; se a resposta dele importar
|
|
205
|
+
para o design, transforme-a em cenário.
|
|
197
206
|
|
|
198
|
-
O `
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
|
|
207
|
+
O plugin `opusDesign()`, de `@softize/opus/vite`, monta o runtime do Opus dentro do dev server e
|
|
208
|
+
responde `/api` no próprio processo. No modo design, qualquer `server.proxy` para um backend externo
|
|
209
|
+
fica desligado: um prefixo que ia para o proxy e não tem action correspondente responde 503 em
|
|
210
|
+
envelope, sem alcançar o backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
|
|
203
211
|
|
|
204
212
|
```ts
|
|
205
213
|
// vite.config.ts — o plugin só carrega no modo design, por import dinâmico.
|
|
@@ -233,9 +241,7 @@ nem reiniciar o dev server. Quando uma action não é encontrada, o plugin recon
|
|
|
233
241
|
vez por ciclo de mudança e tenta de novo; se a action continuar ausente, a resposta indica o
|
|
234
242
|
`entry` a conferir.
|
|
235
243
|
|
|
236
|
-
|
|
237
|
-
chamado. Não o condicione com `import.meta.env` no contrato: essa variável não existe no Node e
|
|
238
|
-
quebraria o servidor.
|
|
244
|
+
Os cenários não entram no bundle da web: eles ficam fora do contrato, e só o plugin os carrega.
|
|
239
245
|
|
|
240
246
|
## Consumir o contrato na interface
|
|
241
247
|
|
|
@@ -15,8 +15,8 @@ pulada, porque o schema descreve o item e o handler devolve a página. Falhas us
|
|
|
15
15
|
`ActionError` tipado do runtime, como `validation.invalid_input`, `auth.unauthenticated`,
|
|
16
16
|
`auth.system_forbidden` e `auth.forbidden`.
|
|
17
17
|
|
|
18
|
-
Passe a action ligada ao handler, produzida por `bindAction`. O contrato puro não tem handler e
|
|
19
|
-
|
|
18
|
+
Passe a action ligada ao handler, produzida por `bindAction`. O contrato puro não tem handler e
|
|
19
|
+
falha com `testing.no_handler`.
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
22
|
import { runAction } from '@softize/opus/testing'
|
|
@@ -86,29 +86,13 @@ const { output, emitted, logged } = await runAction(taskDone, { id: '1' }, { use
|
|
|
86
86
|
expect(emitted).toEqual([{ event: 'task.done', data: { id: '1' } }])
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
##
|
|
89
|
+
## Dados do modo design
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
import { afterEach, it } from 'vitest'
|
|
97
|
-
import { runAction } from '@softize/opus/testing'
|
|
98
|
-
import { Task, taskList } from './list.ts'
|
|
99
|
-
|
|
100
|
-
afterEach(() => {
|
|
101
|
-
// A variável vale para o processo inteiro; limpe para não afetar outros testes.
|
|
102
|
-
delete process.env['OPUS_MODE']
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('o mock devolve itens válidos para o schema', async () => {
|
|
106
|
-
process.env['OPUS_MODE'] = 'design'
|
|
107
|
-
const { output } = await runAction(taskList, {}, { user: { id: 'u1' } })
|
|
108
|
-
// `list` não valida a saída no pipeline: confira item a item.
|
|
109
|
-
for (const item of output.items) expect(Task.safeParse(item).success).toBe(true)
|
|
110
|
-
})
|
|
111
|
-
```
|
|
91
|
+
Os dados que o modo design mostra não são comportamento da action e não precisam de teste de
|
|
92
|
+
unidade: o plugin `opusDesign()` os gera a partir do contrato (ver
|
|
93
|
+
[Actions](/docs/actions)). Enquanto o `mockHandler` existir, obsoleto desde a 22.1.0,
|
|
94
|
+
`runAction` com `OPUS_MODE=design` ainda o executa, como o runtime. Teste a action ligada ao
|
|
95
|
+
handler em vez dele.
|
|
112
96
|
|
|
113
97
|
## memStorage
|
|
114
98
|
|
|
@@ -126,7 +110,7 @@ expect(storage.keys()).toEqual(['notas/9.pdf'])
|
|
|
126
110
|
## fake — dados gerados pelo schema
|
|
127
111
|
|
|
128
112
|
`fake(schema)` e `fakeMany(schema, n)` geram dados sintéticos determinísticos que satisfazem o
|
|
129
|
-
schema zod. Servem
|
|
113
|
+
schema zod. Servem à resposta gerada do modo design e a fixtures de teste. A mesma `seed` produz a
|
|
130
114
|
mesma saída, sem `Math.random`. Os valores seguem as validações do zod (email, uuid, url, datetime)
|
|
131
115
|
e o nome do campo (email, id, `*At` como data, telefone, CPF/CNPJ, nome…), com conteúdo em pt-BR.
|
|
132
116
|
|
package/src/vite/design.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Antes, o dev server proxeava `/api` pra um backend real — o que vaza escrita
|
|
7
7
|
* pra prod e exige infra de pé pra desenhar tela. Com o plugin, `vite --mode
|
|
8
8
|
* design` monta o runtime (driver `nodeServer`) no próprio dev server: os
|
|
9
|
-
*
|
|
9
|
+
* actions respondem pelos dados de design (ADR 0037), qualquer proxy
|
|
10
10
|
* configurado é DESLIGADO (prefixo ex-proxy sem cobertura responde 503 em
|
|
11
11
|
* envelope, nunca vaza), e o Maestro sonda `GET /__opus/design` pra saber que
|
|
12
12
|
* o preview está montado.
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
isDomainConfig,
|
|
45
45
|
isReaction,
|
|
46
46
|
isSchedule,
|
|
47
|
+
type ActionContext,
|
|
47
48
|
type ActionDef,
|
|
48
49
|
type AuthAdapter,
|
|
49
50
|
type ErrorInput,
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
type User,
|
|
54
55
|
} from '../core/index.ts'
|
|
55
56
|
import { readPackageVersion } from '../core/package-version.ts'
|
|
57
|
+
import { designResponse, isDesignScenario, type DesignScenario } from '../design/index.ts'
|
|
56
58
|
import { mittEvents } from '../events/drivers/mitt.ts'
|
|
57
59
|
import { nodeServer, type NodeServerHandle } from '../server/drivers/node.ts'
|
|
58
60
|
import { serializeResult } from '../server/index.ts'
|
|
@@ -84,6 +86,14 @@ export interface OpusDesignOptions {
|
|
|
84
86
|
*/
|
|
85
87
|
entry?: string | string[]
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Cenários de design (ADR 0037): módulos que exportam `defineDesignScenario(contrato, resposta)`.
|
|
91
|
+
* Aceita caminhos e globs, como `entry`. O cenário vence o `mockHandler` e a resposta padrão
|
|
92
|
+
* gerada pelo `fake`; cenário de action inexistente impede o boot, com a lista no erro.
|
|
93
|
+
* Sem default: o projeto decide onde os cenários ficam e se são versionados.
|
|
94
|
+
*/
|
|
95
|
+
scenarios?: string | string[]
|
|
96
|
+
|
|
87
97
|
/** Prefixo do path. Default '/api'. */
|
|
88
98
|
apiPrefix?: string
|
|
89
99
|
|
|
@@ -145,10 +155,14 @@ const PROBE_PATH = '/__opus/design'
|
|
|
145
155
|
// deixá-los chegar no handler (moram fora do apiPrefix).
|
|
146
156
|
const RUNTIME_ENDPOINT_PATHS = new Set(['/health', '/ready', '/openapi.json'])
|
|
147
157
|
|
|
158
|
+
/** De onde vem a resposta de cada action no modo design, na ordem de precedência. */
|
|
159
|
+
type DesignSource = 'scenario' | 'mock' | 'handler' | 'generated'
|
|
160
|
+
|
|
148
161
|
interface DesignState {
|
|
149
162
|
handle: NodeServerHandle
|
|
150
163
|
runtime: Runtime
|
|
151
164
|
actions: ActionDef[]
|
|
165
|
+
sources: Map<string, DesignSource>
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
@@ -156,6 +170,12 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
156
170
|
const entries = Array.isArray(options.entry)
|
|
157
171
|
? options.entry
|
|
158
172
|
: [options.entry ?? './opus.config.ts']
|
|
173
|
+
const scenarioEntries =
|
|
174
|
+
options.scenarios === undefined
|
|
175
|
+
? []
|
|
176
|
+
: Array.isArray(options.scenarios)
|
|
177
|
+
? options.scenarios
|
|
178
|
+
: [options.scenarios]
|
|
159
179
|
const devBackend = options.devBackend === true
|
|
160
180
|
// Mais-específico primeiro: prefixo mais longo vence.
|
|
161
181
|
const stubEntries = Object.entries(options.stubs ?? {}).sort(
|
|
@@ -194,8 +214,8 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
194
214
|
exProxyPrefixes = Object.keys(config.server?.proxy ?? {})
|
|
195
215
|
// 2) Mata o proxy: fecha o vazamento preview→prod pra QUALQUER prefixo.
|
|
196
216
|
if (config.server?.proxy !== undefined) config.server.proxy = undefined
|
|
197
|
-
// 3) O execute() roteia
|
|
198
|
-
//
|
|
217
|
+
// 3) O execute() roteia pro mockHandler lendo OPUS_MODE (isOpusDesignMode); o boot
|
|
218
|
+
// liga em cada action a resposta de design, e o preview fica autossuficiente.
|
|
199
219
|
process.env.OPUS_MODE = 'design'
|
|
200
220
|
},
|
|
201
221
|
|
|
@@ -215,7 +235,14 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
215
235
|
registrables.push(...(await loadEntry(server, entry)))
|
|
216
236
|
}
|
|
217
237
|
}
|
|
218
|
-
const
|
|
238
|
+
const collected = collectActions(registrables)
|
|
239
|
+
const { dropped } = collected
|
|
240
|
+
const scenarios = isDesign
|
|
241
|
+
? await loadScenarios(server, scenarioEntries, collected.actions)
|
|
242
|
+
: new Map<string, DesignScenario>()
|
|
243
|
+
const { actions, sources } = isDesign
|
|
244
|
+
? prepareDesignActions(collected.actions, scenarios)
|
|
245
|
+
: { actions: collected.actions, sources: new Map<string, DesignSource>() }
|
|
219
246
|
|
|
220
247
|
const handle = nodeServer({ apiPrefix })
|
|
221
248
|
const runtime = createRuntime({
|
|
@@ -223,8 +250,8 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
223
250
|
auth: designAuth({ user: options.user, tenantId: options.tenantId }),
|
|
224
251
|
eventBus: mittEvents(),
|
|
225
252
|
storage: memStorage(),
|
|
226
|
-
// SEM `data` de propósito: design não toca ctx.db
|
|
227
|
-
//
|
|
253
|
+
// SEM `data` de propósito: design não toca ctx.db. Uma action ligada a handler real
|
|
254
|
+
// que dependa do banco falha aqui; o caminho é um cenário (ADR 0037).
|
|
228
255
|
...options.runtime,
|
|
229
256
|
config: { env: 'development', dev: true, ...options.runtime?.config },
|
|
230
257
|
})
|
|
@@ -236,19 +263,9 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
236
263
|
`[opus-design] ${dropped} reaction(s)/schedule(s) ignorada(s) — o runtime de design é request/response.`,
|
|
237
264
|
)
|
|
238
265
|
}
|
|
239
|
-
if (isDesign) {
|
|
240
|
-
for (const action of actions) {
|
|
241
|
-
if (action.mockHandler === undefined) {
|
|
242
|
-
logger.warn(
|
|
243
|
-
`[opus-design] Action "${action.name}" sem mockHandler — em design cai no handler real e pode falhar sem adapters.`,
|
|
244
|
-
)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
266
|
await runtime.start()
|
|
250
267
|
handle.adapter.mountEndpoints({ health: true, ready: true, openapi: true })
|
|
251
|
-
return { handle, runtime, actions }
|
|
268
|
+
return { handle, runtime, actions, sources }
|
|
252
269
|
}
|
|
253
270
|
|
|
254
271
|
// Promise-mutex: requests em voo aguardam o MESMO boot; rebuild (stale)
|
|
@@ -285,8 +302,9 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
285
302
|
// add/unlink: QUALQUER arquivo sob o root (fora node_modules/.git) —
|
|
286
303
|
// arquivo novo nunca importado não está no graph, e é exatamente ele
|
|
287
304
|
// que um entry glob precisa enxergar. Over-invalidation é barata.
|
|
305
|
+
const watchedEntries = [...entries, ...scenarioEntries]
|
|
288
306
|
const entryPaths = new Set(
|
|
289
|
-
|
|
307
|
+
watchedEntries
|
|
290
308
|
.filter((entry) => !isGlobPattern(entry))
|
|
291
309
|
.map((entry) => path.resolve(server.config.root, entry)),
|
|
292
310
|
)
|
|
@@ -294,7 +312,7 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
294
312
|
// pode apontar pra FORA dele (ex.: '../../services/*/contract.ts', caso GB):
|
|
295
313
|
// mandamos o chokidar observar o PREFIXO ESTÁTICO de cada glob — o mesmo que
|
|
296
314
|
// o expandGlob anda, nunca o mundo — pra change/unlink lá também invalidar.
|
|
297
|
-
const globSpecs =
|
|
315
|
+
const globSpecs = watchedEntries
|
|
298
316
|
.filter(isGlobPattern)
|
|
299
317
|
.map((pattern) => globSpec(server.config.root, pattern))
|
|
300
318
|
for (const { prefixDir } of globSpecs) server.watcher.add(prefixDir)
|
|
@@ -341,12 +359,21 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
341
359
|
}
|
|
342
360
|
try {
|
|
343
361
|
const state = await ensureBoot()
|
|
344
|
-
const
|
|
362
|
+
const named = (source: DesignSource): string[] =>
|
|
363
|
+
state.actions.filter((a) => state.sources.get(a.name) === source).map((a) => a.name)
|
|
364
|
+
const scenarios = named('scenario')
|
|
365
|
+
const mocks = named('mock')
|
|
345
366
|
payload.runtime = {
|
|
346
367
|
prefix: apiPrefix,
|
|
347
368
|
actions: state.actions.length,
|
|
348
|
-
mocked:
|
|
349
|
-
|
|
369
|
+
// `mocked`/`unmocked` mantêm o formato anterior da sonda: resposta escrita à mão
|
|
370
|
+
// (cenário ou mockHandler) contra o resto, que responde por handler real ou `fake`.
|
|
371
|
+
mocked: scenarios.length + mocks.length,
|
|
372
|
+
unmocked: state.actions
|
|
373
|
+
.filter((a) => !['scenario', 'mock'].includes(state.sources.get(a.name) ?? ''))
|
|
374
|
+
.map((a) => a.name),
|
|
375
|
+
scenarios,
|
|
376
|
+
generated: named('generated'),
|
|
350
377
|
}
|
|
351
378
|
} catch (err) {
|
|
352
379
|
payload.error = String(err)
|
|
@@ -605,6 +632,89 @@ function registrablesOfModule(mod: Record<string, unknown>): unknown[] {
|
|
|
605
632
|
return [...out]
|
|
606
633
|
}
|
|
607
634
|
|
|
635
|
+
// =============================================================================
|
|
636
|
+
// Dados de design (ADR 0037)
|
|
637
|
+
// =============================================================================
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Carrega os cenários dos módulos casados por `scenarios`. Cenário de action inexistente ou dois
|
|
641
|
+
* cenários para a mesma action impedem o boot: o preview mostra o erro em vez de silenciar.
|
|
642
|
+
*/
|
|
643
|
+
async function loadScenarios(
|
|
644
|
+
server: ViteDevServer,
|
|
645
|
+
patterns: string[],
|
|
646
|
+
actions: ActionDef[],
|
|
647
|
+
): Promise<Map<string, DesignScenario>> {
|
|
648
|
+
const scenarios = new Map<string, DesignScenario>()
|
|
649
|
+
const known = new Set(actions.map((action) => action.name))
|
|
650
|
+
const unknown: string[] = []
|
|
651
|
+
const duplicated: string[] = []
|
|
652
|
+
for (const pattern of patterns) {
|
|
653
|
+
const files = isGlobPattern(pattern) ? expandGlob(server.config.root, pattern) : [normalizeEntryUrl(pattern)]
|
|
654
|
+
for (const file of files) {
|
|
655
|
+
const mod = (await server.ssrLoadModule(file)) as Record<string, unknown>
|
|
656
|
+
for (const scenario of scenariosOfModule(mod)) {
|
|
657
|
+
if (!known.has(scenario.action)) unknown.push(scenario.action)
|
|
658
|
+
else if (scenarios.has(scenario.action) && scenarios.get(scenario.action) !== scenario) {
|
|
659
|
+
duplicated.push(scenario.action)
|
|
660
|
+
} else scenarios.set(scenario.action, scenario)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (unknown.length > 0 || duplicated.length > 0) {
|
|
665
|
+
const names = (list: string[]): string => [...new Set(list)].join(', ')
|
|
666
|
+
const problems = [
|
|
667
|
+
unknown.length > 0 ? `sem action registrada: ${names(unknown)}` : '',
|
|
668
|
+
duplicated.length > 0 ? `mais de um cenário para: ${names(duplicated)}` : '',
|
|
669
|
+
].filter(Boolean)
|
|
670
|
+
throw error({
|
|
671
|
+
code: 'design.invalid_scenario',
|
|
672
|
+
category: 'internal',
|
|
673
|
+
message: `Cenários de design inválidos (${problems.join('; ')}).`,
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
return scenarios
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function scenariosOfModule(mod: Record<string, unknown>): DesignScenario[] {
|
|
680
|
+
const out = new Set<DesignScenario>()
|
|
681
|
+
const add = (value: unknown): void => {
|
|
682
|
+
if (Array.isArray(value)) value.forEach(add)
|
|
683
|
+
else if (isDesignScenario(value)) out.add(value)
|
|
684
|
+
}
|
|
685
|
+
Object.values(mod).forEach(add)
|
|
686
|
+
return [...out]
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Liga a cada action a resposta de design, nesta precedência: cenário, `mockHandler` declarado,
|
|
691
|
+
* handler real (`bindAction`) e, por fim, a resposta padrão gerada pelo `fake`.
|
|
692
|
+
*/
|
|
693
|
+
function prepareDesignActions(
|
|
694
|
+
actions: ActionDef[],
|
|
695
|
+
scenarios: Map<string, DesignScenario>,
|
|
696
|
+
): { actions: ActionDef[]; sources: Map<string, DesignSource> } {
|
|
697
|
+
const sources = new Map<string, DesignSource>()
|
|
698
|
+
const prepared = actions.map((action): ActionDef => {
|
|
699
|
+
const scenario = scenarios.get(action.name)
|
|
700
|
+
if (scenario !== undefined) {
|
|
701
|
+
sources.set(action.name, 'scenario')
|
|
702
|
+
return { ...action, mockHandler: (ctx: ActionContext, input: unknown) => scenario.respond(input, ctx) } as ActionDef
|
|
703
|
+
}
|
|
704
|
+
if (action.mockHandler !== undefined) {
|
|
705
|
+
sources.set(action.name, 'mock')
|
|
706
|
+
return action
|
|
707
|
+
}
|
|
708
|
+
if (typeof (action as { handler?: unknown }).handler === 'function') {
|
|
709
|
+
sources.set(action.name, 'handler')
|
|
710
|
+
return action
|
|
711
|
+
}
|
|
712
|
+
sources.set(action.name, 'generated')
|
|
713
|
+
return { ...action, mockHandler: async (_ctx: ActionContext, input: unknown) => designResponse(action, input) } as ActionDef
|
|
714
|
+
})
|
|
715
|
+
return { actions: prepared, sources }
|
|
716
|
+
}
|
|
717
|
+
|
|
608
718
|
/** Achata os registráveis em actions; reactions/schedules ficam de fora (contados). */
|
|
609
719
|
function collectActions(items: unknown[]): { actions: ActionDef[]; dropped: number } {
|
|
610
720
|
const actions: ActionDef[] = []
|
|
@@ -621,7 +731,7 @@ function collectActions(items: unknown[]): { actions: ActionDef[]; dropped: numb
|
|
|
621
731
|
dropped += 1
|
|
622
732
|
continue
|
|
623
733
|
}
|
|
624
|
-
// ActionDef ou contrato (sem handler): em design
|
|
734
|
+
// ActionDef ou contrato (sem handler): em design, a resposta de design responde.
|
|
625
735
|
actions.push(item as ActionDef)
|
|
626
736
|
}
|
|
627
737
|
return { actions, dropped }
|