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 })
+ })
+})