Merge pull request #611 from fsolla/OPS34-ci-e2e-paralelizar-o-job-cadeia-serial-so-em-dev-shard

chore(OPS34): e2e CI em 2 shards e cadeia de projetos dev-only
This commit is contained in:
Francisco Solla 2026-08-10 15:07:51 -03:00 committed by GitHub
commit 97be0c28d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 267 additions and 14 deletions

View file

@ -38,6 +38,8 @@ jobs:
test_mode: ${{ steps.classify.outputs.test_mode }}
e2e_mode: ${{ steps.classify.outputs.e2e_mode }}
e2e_specs: ${{ steps.classify.outputs.e2e_specs }}
e2e_matrix: ${{ steps.classify.outputs.e2e_matrix }}
e2e_shards: ${{ steps.classify.outputs.e2e_shards }}
steps:
- uses: actions/checkout@v5
with:
@ -60,6 +62,11 @@ jobs:
echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT"
echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT"
echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
# OPS34: full runs split across shards (one runner per shard); selected
# stays on a single runner (a second build for a handful of specs is
# waste). Single source: e2eShardConfig in scripts/lib/test-affected-core.mjs.
echo "e2e_matrix=$(echo "$result" | jq -c .e2e_shards.matrix)" >> "$GITHUB_OUTPUT"
echo "e2e_shards=$(echo "$result" | jq -r .e2e_shards.total)" >> "$GITHUB_OUTPUT"
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure
@ -287,6 +294,15 @@ jobs:
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
# OPS34: full mode splits across 2 runners (matrix from the scope job);
# selected mode stays on one. fail-fast: false keeps both shards' logs.
strategy:
fail-fast: false
matrix:
shard: ${{ fromJson(needs.scope.outputs.e2e_matrix) }}
env:
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
PLAYWRIGHT_WORKERS: 2
services:
postgres:
image: postgres:17-alpine
@ -320,9 +336,11 @@ jobs:
env:
NEXT_DIST_DIR: .next/e2e
- name: E2E tests (full suite)
- name: E2E tests (full suite, shard ${{ matrix.shard }})
if: needs.scope.outputs.e2e_mode == 'full'
run: pnpm test:e2e
# No `--` separator: pnpm would forward it verbatim to playwright and
# the flag would be parsed as a file pattern ("No tests found").
run: pnpm test:e2e --shard=${{ matrix.shard }}/${{ needs.scope.outputs.e2e_shards }}
- name: E2E tests (manifest-selected specs)
if: needs.scope.outputs.e2e_mode == 'selected'

View file

@ -111,6 +111,19 @@ jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 40
# Main always runs the full suite: split it across 2 runners (OPS34), each
# building its own `.next/e2e` (measured 2026-08-10: build 172s + tests
# 196s + ~54s fixed → ≈ 5.5 min per shard vs 435s serial). `fail-fast:
# false` so one shard's failure keeps the other's diagnostics. Keep the
# [1, 2] / 2 literal in sync with `e2eShardConfig` in
# scripts/lib/test-affected-core.mjs (ci-pr reads it via the scope job).
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
PLAYWRIGHT_WORKERS: 2
services:
postgres:
image: postgres:17-alpine
@ -141,8 +154,10 @@ jobs:
run: pnpm build
env:
NEXT_DIST_DIR: .next/e2e
- name: E2E tests (full suite)
run: pnpm test:e2e
- name: E2E tests (full suite, shard ${{ matrix.shard }})
# No `--` separator: pnpm would forward it verbatim to playwright and
# the flag would be parsed as a file pattern ("No tests found").
run: pnpm test:e2e --shard=${{ matrix.shard }}/2
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-10):** **OPS34** (Issue #599) — o job `e2e` do CI (o mais longo da cascata, 435 s medidos no run 31412425553: ~54 s fixos + build 172 s + testes 196 s) roda agora **em 2 shards paralelos** (`--shard=N/2`, matrix `fail-fast: false`, build por shard — opção A da intenção validada pela medição: por shard ≈ 5–5,5 min; artefato de build compartilhado rejeitado porque os shards só começam após o build → wall maior). **Cadeia de projetos do Playwright (`setup → campaign → frontend → admin`) + prewarm do `setup` viraram dev-only:** `playwright.config.ts` ganhou `isProdMode = CI || E2E_PROD=1` (mesma condição do `webServer.command`, agora reusada) — em prod o projeto `setup` sai do array (arquivo não casado = não coletado) e as `dependencies` zeram (famílias em paralelo contra o build de produção, onde não há compile a preaquecer); dev intocado (provado: `--list` mostra `[setup]` só sem CI; smoke e2e dev rodou setup→campaignAuth). `ci-pr.yml`: o job `scope` emite `e2e_matrix`/`e2e_shards` via novo `e2eShardConfig(mode)` em `scripts/lib/test-affected-core.mjs` (fonte única, unit-pinada: full → `[1,2]`/2, selected/none → `[1]`/1 — pagar 2 builds por 3 specs é desperdício); `ci.yml` hardcodeia `[1,2]`/2 com comentário apontando o single-source. `PLAYWRIGHT_WORKERS: 2` explícito no env dos jobs e2e (aceite: explícito, default local intocado). Armadilha registrada nos workflows: flags do playwright vão **sem** o separador `--` — pnpm repassa o `--` literal ao playwright e a flag vira file pattern ("No tests found"). Specs intactas (o `setup` não foi removido — virou dev-only, já documentado como prewarm dev-only); sem migration/access/Consent/UI. Medição pós-merge do job e2e do ci.yml a registrar aqui. Intenção [`ci-e2e-paralelizar-job.md`](plans/ci-e2e-paralelizar-job.md); impl [`ci-e2e-paralelizar-job-impl.md`](plans/ci-e2e-paralelizar-job-impl.md).
**Recently resolved (2026-08-10):** **OPS22** (Issue #562) — CI de `main` vermelho determinístico desde o merge do B188 (05:54): os 3 e2e da agenda mobile (`campaignAgendaMobile` :96/:242/:295) falhavam com todas as retries → deploy bloqueado. **Root cause (reproduzida no prod build local + error-contexts com o drawer aberto):** o login e2e roda em **viewport desktop** → o settle de B167 abre o chat (`open=true`) → o **efeito persist do B188** (status `ready` já nasce) grava `{open:true}` no `sessionStorage` → o spec muda para 390px na **mesma aba** → o **restore do B188** reabre o **drawer mobile sozinho** → o modal Radix **aria-hide + inert** a página e cobre o viewport (combobox fora da a11y tree no :242, swipe cai no drawer no :96, clique do seletor bloqueado no :295; teclado/:173 passam por não usarem hit-testing). **Defeito real de produto** (aba desktop → mobile na mesma aba faz o chat "saltar"), não só timing de teste. **Fix na origem:** `SollinhaChatSession` ganhou **`openBy: 'user' | 'settle'`** (opcional — sessão legada sem o campo = `'settle'`, fail-closed; sem bump de versão) e o provider agora distingue **intenção do usuário** (`setOpen`/`toggle` do context value marcam `userToggledOpenRef`; FAB/header/drawer já passam por eles) de **verdade de layout** (settle B167 usa o setter cru): o **restore espera o `measured`** (viewport real) e honra `open:true` em mobile **só quando `openBy==='user'`**; o persist grava `openBy` conforme a intenção. Todos os contratos B188/resize mantidos (reload restaura conversa; drawer aberto por usuário volta aberto; visita mobile nova fechada; crossing ao vivo B167 intocado). Testes: unit (round-trip/invalidez/legado de `openBy`) + **e2e pin OPS22** no spec B188 (login desktop → poll do persist `open:true` → viewport 390 → `/campanha/agenda` → sem dialog + botão do Sollinha visível); spec da agenda intacto (4/4 no prod build). Sem migration/access/Consent/schema. Intenção [`ci-e2e-agenda-mobile-regressao-b188.md`](plans/ci-e2e-agenda-mobile-regressao-b188.md); impl [`ci-e2e-agenda-mobile-regressao-b188-impl.md`](plans/ci-e2e-agenda-mobile-regressao-b188-impl.md).
**Recently resolved (2026-08-10):** **OPS31** (Issue #593) — `worktree plan` no terminal agora abre o TUI do opencode com `/plan-issue` **já enviado** (a revisão da proposição do opencode mostrou que a necessidade real do fluxo era auto-envio, não pre-fill: `--prompt` sempre auto-submete, e o `next` já provava com `/work-issue` — o `plan` só ficou sem prompt por uma premissa equivocada da OPS26). Mudança de valor no mapa existente: `OPENCODE_SKILL_COMMAND_BY_PURPOSE.plan: null → '/plan-issue'` (`scripts/lib/worktree.mjs`); `next` (`/work-issue`) e `new` (prompt-less, "apenas conversar") intocados; o mecanismo OPS26 já injetava `--prompt` quando o valor existia — zero código novo. Textos correlatos atualizados (docblock/uso do script, `worktree.sh`, AGENTS.md, skill `worktree-next-issue`); o tom do gap mudou: prefill-without-submit segue como feature request upstream (proposição `~/Code/propositions/opencode/prefill-prompt-sem-submit.md` anotada com o desfecho — edição local, fora do repo), não mais fallback do Teqo; entradas OPS26/OPS27 do changelog permanecem como histórico. Unit: teste "plan launches with `/plan-issue` sent" + pin de constantes `{ next: '/work-issue', plan: '/plan-issue', new: null }`. Sem migration/dados/access/UI. Intenção [`worktree-plan-abre-opencode-com-plan-issue.md`](plans/worktree-plan-abre-opencode-com-plan-issue.md); impl [`worktree-plan-abre-opencode-com-plan-issue-impl.md`](plans/worktree-plan-abre-opencode-com-plan-issue-impl.md).

View file

@ -0,0 +1,168 @@
# Impl: CI e2e — paralelizar o job (cadeia serial só em dev + sharding do e2e)
Status: aprovado
Atualizado em: 2026-08-10
Issue: #599
Intenção: docs/plans/ci-e2e-paralelizar-job.md
Appetite restante: herdado (~1 dia eng; entrega é ~5 arquivos, sem migration/UI)
## Leitura da intenção
- **Outcome:** job `e2e` do `ci.yml` cai de ~10 min para ≤ ~6 min de wall time
(mesmo conjunto de testes, mesma confiança); cadeia de projetos + prewarm do
`setup` continuam existindo **só em dev**; workers do CI explícitos, default
local intocado; sem classe nova de flake.
- **O que NÃO negociar:** nenhuma spec de produto removida/alterada (campaign/
frontend/admin); dev local (`pnpm test:e2e` sem CI/E2E_PROD) idêntico; main
e PR full rodam a suite completa; cobertura inalterada.
- **O que reavaliar:** a hipótese listava `scripts/gate-ci.mjs` e
`scripts/run-e2e-affected.mjs` como áreas. **Não serão tocados**: ambos
delegam a `pnpm test:e2e`, e a cadeia condicional mora no config — a
paridade local com CI vem de graça (gate-ci já seta `CI=1 E2E_PROD=1` → modo
prod → sem cadeia). Colocar sharding nesses scripts duplicaria política de
CI na máquina local sem ganho (shard é paralelismo de _runners_ de CI).
### Medição de base (real, run 31412425553 do ci.yml, 2026-08-10)
Job e2e completo: **435 s** (17:07:16 → 17:14:31). Decomposição por step:
| Fase | Tempo |
| ---------------------------------- | --------- |
| setup/checkout/tooling | ~17 s |
| pnpm install (cache) | 5 s |
| install playwright chromium | 19 s |
| migrate + seed:minimal | 13 s |
| **build (`.next/e2e`)** | **172 s** |
| **testes (suite full, 2 workers)** | **196 s** |
(A intenção citava 617–635 s — runs mais lentos do mesmo dia; a decomposição
acima é a mais recente e é a linha de base usada aqui.)
## Abordagem recomendada
```mermaid
flowchart LR
subgraph ci.yml[ci.yml (main) — e2e sempre full]
E2E1[e2e shard 1<br/>build + test --shard=1/2] --> C1[checks]
E2E2[e2e shard 2<br/>build + test --shard=2/2] --> C1
end
subgraph ci-pr.yml[ci-pr.yml (PR)]
S[scope job<br/>e2e_mode + e2e_matrix] --> P1[e2e full: matrix [1,2]<br/>--shard=N/2]
S --> P2[e2e selected: matrix [1]<br/>specs sem shard]
P1 --> CP[checks]
P2 --> CP
end
subgraph config[playwright.config.ts]
C[isProdMode = CI ou E2E_PROD=1<br/>→ sem projeto setup, sem dependencies<br/>dev → cadeia + prewarm atuais]
end
P1 -. roda sobre .-> C
E2E1 -. roda sobre .-> C
```
**Opções consideradas:** A) build por shard (cada runner compila) · B) job de
build + artefato `.next/e2e` compartilhado · C) 3 shards já de início
**Recomendação:** **A com 2 shards** — porque a medição real decide: por shard
= 54 s (fixo) + 172 s (build) + ~98 s (196/2) ≈ **324 s ≈ 5,4 min**; e com a
cadeia removida os testes encolhem mais (~120–150 s full → ~75 s por shard),
chegando a ~5 min. O caminho crítico do pipeline (max entre e2e/build/int)
passa de 435 s para ~300–330 s.
**Rejeitadas:** **B** porque os shards só podem começar depois do build
terminar: wall = build 172 + upload + download + teste/2 ≫ A (a transferência
de um `.next` inteiro supera o build paralelo de cada shard); o `build` job do
ci.yml **não** serve (compila `.next` default e o servidor e2e usa
`.next/e2e` via `NEXT_DIST_DIR` — mudar isso é mexer no contrato do webServer
para nada). **C** porque o ganho é marginal (~30 s a mais) ao custo de um
runner e de mais pressão de quota; fica como gatilho de revisitação se o wall
por shard passar de ~6,5 min ou a distribuição de testes ficar desbalanceada.
### Componentes / mudanças
- **`playwright.config.ts`**: hoist de `isProdMode = Boolean(process.env.CI)
|| process.env.E2E_PROD === '1'` (mesma condição do `webServer.command`
atual — agora reusada lá e nos projects). Em prod mode: projeto `setup`
**removido** do array de projects e `dependencies` de campaign/frontend/
admin viram `[]` (o `setup.e2e.spec.ts` fica sem project → Playwright ignora
arquivo não casado, sem erro). Em dev: estrutura idêntica à de hoje
(cadeia `setup → campaign → frontend → admin`, `fullyParallel: false` do
admin preservado nos dois modos). Comentário no config explica o porquê.
- **`scripts/lib/test-affected-core.mjs`**: novo `e2eShardConfig(mode)` puro →
`'full'` retorna `{ matrix: [1, 2], total: 2 }`; `'selected'`/`'none'`
retornam `{ matrix: [1], total: 1 }` — com comentário da medição e do
gatilho de 3 shards. (Módulo já é o dono da seleção e2e da CI, já unitado.)
- **`scripts/ci-scope.mjs`**: output JSON ganha `e2e_shards` (resultado do
helper acima).
- **`.github/workflows/ci.yml`**: job `e2e` vira matrix `shard: [1, 2]`
(`fail-fast: false`; comentário apontando o single-source em
test-affected-core); step de teste vira `pnpm test:e2e -- --shard=${{ matrix.shard }}/2`;
env de job `PLAYWRIGHT_WORKERS: 2` explícito (aceite #4).
- **`.github/workflows/ci-pr.yml`**: job `scope` emite `e2e_matrix` +
`e2e_shards`; job `e2e` usa `strategy.matrix.shard: ${{ fromJson(...) }}`
(full → `[1,2]`, selected → `[1]` — selected continua num runner só: pagar
2 builds por 3 specs é desperdício); step full com
`--shard=${{ matrix.shard }}/${{ needs.scope.outputs.e2e_shards }}`; step
selected inalterado; env de job `PLAYWRIGHT_WORKERS: 2`.
- **Migration:** nenhuma (sem schema).
- **Access / Consent:** N/A.
- **UI:** N/A (Impeccable A — infra de CI/testes).
### Nota de aceite — o spec `setup` e "nenhuma spec removida"
O `setup.e2e.spec.ts` não é removido nem alterado: ele vira **dev-only** (já é
documentado como prewarm dev-only no próprio config e no issue). Em CI ele é
custo puro (25 requisições de prewarm contra um build de produção, sem
compile), e nenhum spec de produto depende dele em prod. A suite completa de
produto (campaign/frontend/admin) roda em main e PR full, intacta.
## Fases verificáveis
1. **Infra (config + workflows + helper)** — as 5 mudanças acima, num único
commit de CI (sem migration, sem UI).
2. **Verificação local** —
- `pnpm exec playwright test --list` (dev): setup presente, deps `[setup]`/
`[campaign]`/`[frontend]`; `CI=1 pnpm exec playwright test --list`: sem
projeto setup, sem deps.
- `pnpm test:unit` (pin novo do `e2eShardConfig`) + `pnpm gate:fast`.
- Smoke e2e dev de uma spec `campaign*` + uma `admin` (prova que o modo dev
segue com cadeia e prewarm).
3. **Verificação CI** — o próprio PR exercita o novo matrix em full
(`playwright.config.ts` está em `HIGH_RISK_EXACT` → e2e_mode full):
conferir 2 shards rodando em paralelo e `checks` verde. Pós-merge: medir o
job e2e do ci.yml e registrar antes/depois no changelog.
## Rabbit holes / Não escopo (engenharia)
- Subir workers do CI além de 2 (aceite pede explícito, não maior).
- Shard por família/afinidade (corte da intenção — `--shard` padrão).
- Sharding do modo `selected` do PR (gatilho: wall selected recorrente > 10
min).
- Otimizar o job `build`/`int` (fora do escopo da intenção).
- Mexer em `gate-ci.mjs`/`run-e2e-affected.mjs` (paridade herda do config).
- Reduzir `timeout-minutes` dos jobs (sem ganho de aceite, risco de
falso-positivo em run lento).
## Riscos e mitigação
- **Desbalanceamento de shard** (Playwright distribui por test ID): aceitável
com fullyParallel; gatilho de 3 shards se o wall passar de ~6,5 min.
- **Famílias interleaved em prod** (antes: campaign → frontend → admin
seriadas): servidor é build de produção (sem compile race); specs são
auto-contidas (fixtures por spec + advisory locks do testDatabaseLease); o
número de workers por run não muda (2) — a classe de flake por carga não
cresce. A run do PR full valida.
- **`fromJson` de matrix com output vazio no ci-pr**: o job `scope` sempre
emite `e2e_matrix` válido (`[1]` no mínimo) antes do `e2e` avaliar o matrix
(que já depende de `scope`); job com `e2e_mode == 'none'` é skipado pelo
`if:` existente — o matrix de `[1]` nunca dispara run extra.
- **Drift do número 2 entre ci.yml (literal) e `e2eShardConfig`**: comentários
cruzados nos dois arquivos + entrada do changelog; mudar para 3 é editar os
dois pontos apontados.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (mapa: wall ≤ ~6 min ✓,
suite completa em main/PR-full ✓, dev intocado ✓, workers explícitos ✓,
flakes: mesma carga por run)
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access,
sem UI; gate:fast + gate de CI no fechamento)
- [x] Testes previstos: unit do `e2eShardConfig` + `--list` nos dois modos +
smoke e2e dev + run do PR full (2 shards)

View file

@ -27,6 +27,17 @@ const workers = workersOverride === undefined ? 2 : Number(workersOverride)
if (!Number.isInteger(workers) || workers < 2) {
throw new Error('PLAYWRIGHT_WORKERS must be an integer of at least 2.')
}
/*
* Prod mode (CI or E2E_PROD=1) serves the PRODUCTION build, where no dev-mode
* compile exists. The `setup` project (route prewarm) and the project chain
* (setup → campaign → frontend → admin) exist to stabilize dev-mode cold
* compiles; against a production build they are pure cost — measured 2026-08-10
* (OPS34): the 25 prewarm requests plus the 4-test frontend/admin tail behind
* the ~30-file campaign family. Prod mode therefore drops the setup project and
* the dependencies so all families run in parallel; CI shards the suite across
* runners instead (ci.yml / ci-pr.yml pass `--shard`). Dev mode is unchanged.
*/
const isProdMode = Boolean(process.env.CI) || process.env.E2E_PROD === '1'
/**
* See https://playwright.dev/docs/test-configuration.
@ -67,27 +78,37 @@ export default defineConfig({
timeout: 10_000,
},
projects: [
{
name: 'setup',
testMatch: /setup\.e2e\.spec\.ts/,
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
/*
* `setup` prewarms shared Next route bundles against the dev server's cold
* compile cache. In prod mode there is no compile to prewarm and the whole
* chain is pure cost (see `isProdMode` above), so the setup project only
* exists in dev; an unmatched spec file is simply not collected.
*/
...(isProdMode
? []
: [
{
name: 'setup',
testMatch: /setup\.e2e\.spec\.ts/,
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
]),
{
name: 'campaign',
testMatch: /campaign.*\.e2e\.spec\.ts/,
dependencies: ['setup'],
dependencies: isProdMode ? [] : ['setup'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
name: 'frontend',
testMatch: /frontend\.e2e\.spec\.ts/,
dependencies: ['campaign'],
dependencies: isProdMode ? [] : ['campaign'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
name: 'admin',
testMatch: /admin\.e2e\.spec\.ts/,
dependencies: ['frontend'],
dependencies: isProdMode ? [] : ['frontend'],
fullyParallel: false,
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
@ -102,7 +123,7 @@ export default defineConfig({
* (CampaignListSheetProvider, 2026-07-30). Locally `pnpm dev` stays the
* default: no build wait and hot reload while writing specs.
*/
command: process.env.CI || process.env.E2E_PROD === '1' ? 'pnpm start' : 'pnpm dev',
command: isProdMode ? 'pnpm start' : 'pnpm dev',
/*
* Never reuse a dev server a developer may already have running against the
* production database — always start a fresh one bound to the test DB.
@ -111,7 +132,7 @@ export default defineConfig({
url: baseURL,
// Local-only tolerance for parallel-worktree load (machine load ~60); CI
// boots the production build and keeps the usual 60s budget.
timeout: process.env.CI ? undefined : 240_000,
timeout: isProdMode ? undefined : 240_000,
/* Force the isolated test database into the server process. */
env: {
DATABASE_URL: process.env.DATABASE_URL as string,

View file

@ -13,6 +13,7 @@ import {
classifyBuildScope,
classifyStaticScope,
classifyTestScope,
e2eShardConfig,
selectE2eSpecs,
} from './lib/test-affected-core.mjs'
@ -63,5 +64,6 @@ console.log(
build: classifyBuildScope(files),
test: classifyTestScope(files),
e2e,
e2e_shards: e2eShardConfig(e2e.mode),
}),
)

View file

@ -236,6 +236,21 @@ export function selectE2eSpecs(files, manifest) {
}
}
/**
* CI job-level parallelism for the e2e suite (OPS34). The full run splits
* across 2 shards (`--shard=N/total`); selected and none stay on a single
* runner — a second build for a handful of specs is pure waste. Measured
* 2026-08-10 (ci.yml run 31412425553): build 172s + tests 196s + ~54s fixed,
* so shard 1 and 2 each land ≈ 5–5.5 min. Revisit at 3 shards if the sharded
* wall drifts past ~6.5 min or the test split gets lopsided; ci.yml hardcodes
* the same [1, 2] / 2 literal with a comment pointing back here.
* @param {'full' | 'selected' | 'none'} mode
* @returns {{ matrix: number[], total: number }}
*/
export function e2eShardConfig(mode) {
return mode === 'full' ? { matrix: [1, 2], total: 2 } : { matrix: [1], total: 1 }
}
/**
* Walk a list of repo-relative paths and return misplaced spec/test files.
* @param {string[]} paths

View file

@ -6,6 +6,7 @@ import {
classifyBuildScope,
classifyStaticScope,
classifyTestScope,
e2eShardConfig,
HIGH_RISK_EXACT,
HIGH_RISK_PREFIXES,
selectE2eSpecs,
@ -115,3 +116,14 @@ describe('selectE2eSpecs (OPS5)', () => {
expect(result).toMatchObject({ mode: 'none', specs: [], unmapped: [] })
})
})
describe('e2eShardConfig (OPS34)', () => {
it('splits the full suite across 2 shards', () => {
expect(e2eShardConfig('full')).toEqual({ matrix: [1, 2], total: 2 })
})
it('keeps selected and skipped runs on a single runner', () => {
expect(e2eShardConfig('selected')).toEqual({ matrix: [1], total: 1 })
expect(e2eShardConfig('none')).toEqual({ matrix: [1], total: 1 })
})
})