diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index b4f19f99..79bcb203 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -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' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33da2960..591ca560 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index a70f8861..aeac6bb4 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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). diff --git a/docs/plans/ci-e2e-paralelizar-job-impl.md b/docs/plans/ci-e2e-paralelizar-job-impl.md new file mode 100644 index 00000000..3ba56daf --- /dev/null +++ b/docs/plans/ci-e2e-paralelizar-job-impl.md @@ -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
build + test --shard=1/2] --> C1[checks] + E2E2[e2e shard 2
build + test --shard=2/2] --> C1 + end + subgraph ci-pr.yml[ci-pr.yml (PR)] + S[scope job
e2e_mode + e2e_matrix] --> P1[e2e full: matrix [1,2]
--shard=N/2] + S --> P2[e2e selected: matrix [1]
specs sem shard] + P1 --> CP[checks] + P2 --> CP + end + subgraph config[playwright.config.ts] + C[isProdMode = CI ou E2E_PROD=1
→ sem projeto setup, sem dependencies
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) diff --git a/playwright.config.ts b/playwright.config.ts index cccf768a..d99591d5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 79cb5c4a..e3f126f6 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -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), }), ) diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index c8b4ff33..7b8a4b70 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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 diff --git a/tests/unit/testAffected.unit.spec.ts b/tests/unit/testAffected.unit.spec.ts index e1fa5cf3..7996b3c6 100644 --- a/tests/unit/testAffected.unit.spec.ts +++ b/tests/unit/testAffected.unit.spec.ts @@ -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 }) + }) +})