589ced580c
8 plan files updated to reflect what's actually shipped vs. what each doc said before: - 2026-05-06-cross-stack-interop-test.md: 'Spec — ready to implement' → 'Implemented and merged' with pointers to results, gap matrix, and closure roadmap. - 2026-05-06-cross-stack-interop-test-results.md: scenario inventory updated to show all branches merged into claude/cross-stack-interop-test-XAbYB; suite-flake caveats flagged with cross-refs to the routing investigation; file inventory now lists BrowserInteropTest + PlaywrightDriver + the moved nestsClient/tests/browser-interop/ tree (no more 'in sister branches not yet merged' section). - 2026-05-06-cross-stack-interop-test-gap-matrix.md: T8/T10/T11/ T12/T13 all show hang ✅ + browser ✅; I13/I14 'NOT YET LANDED' callouts removed; the 'in sister branch' qualifier on T13 / I7 / I6 dropped; coverage-holes section replaced with caveat pointers to the routing investigation. - 2026-05-06-i4-stereo-cross-stack-scenario.md: 'Spec — ready for implementation' → 'Landed' with PR #2755 + the three test scenarios that ship the assertion. - 2026-05-06-phase4-browser-harness.md: 'Spec — ready for implementation' → 'Landed', with notes on how the implementation diverged (used Container.Legacy.Consumer not @moq/watch; cert pinning via serverCertificateHashes; I2/I3/I4/I13/I14 originally deferred but later landed). - 2026-05-06-phase4-browser-harness-results.md: status updated to reflect 4.D CI removed per maintainer ask; layout note for the nestsClient/tests/browser-interop/ relocation; full scenario list reconciled with results doc. - 2026-05-07-late-join-catalog-flake-investigation.md: status updated to mention00f6cba31+207057374were reverted, and link to 2026-05-07-moq-relay-routing-investigation.md as the action plan. Adds note that the flake also affects four browser-tier scenarios after Browser I7 landed. - 2026-05-01-quic-stream-cliff-investigation.md: adds an HCgOY- override banner — recommendation 2 (framesPerGroup = 5) was superseded 4 days later by HCgOY field tests landing the prod default at 50. Cross-refs the reconciliation + production- rerun plans. Open follow-up #3 ('reset to 1') updated with the same context. No code or test changes.
444 lines
19 KiB
Markdown
444 lines
19 KiB
Markdown
# Plan: Phase 4 — browser-side cross-stack harness (T16)
|
||
|
||
**Status:** ✅ Landed. Browser harness lives at
|
||
`nestsClient/tests/browser-interop/`; tests are
|
||
`BrowserInteropTest.kt` covering I1, I2, I3, I4 (stereo), I5
|
||
(hot-swap), I7 (publisher reconnect), I9 (packet loss), I13
|
||
(long broadcast), I14 (WebCodecs warmup × CSD), I15 (ALPN).
|
||
Companion landed-results doc:
|
||
`2026-05-06-phase4-browser-harness-results.md`.
|
||
|
||
The spec text below is preserved for archaeology; some pieces
|
||
shifted during implementation:
|
||
- `@moq/watch` / `@moq/publish` weren't directly used; the
|
||
harness uses `@moq/lite` + `@moq/hang` `Container.Legacy.Consumer/Producer`
|
||
because the published `@moq/hang` 0.2.4 didn't expose the
|
||
high-level `Container.Consumer` API.
|
||
- Cert pinning uses `serverCertificateHashes` not
|
||
`--ignore-certificate-errors` because Chromium's flag does
|
||
NOT bypass QUIC cert validation.
|
||
- Phase 4.C originally deferred I2/I3/I4/I13/I14 — those
|
||
subsequently landed, see results doc.
|
||
|
||
**Origin:** parent plan
|
||
`nestsClient/plans/2026-05-06-cross-stack-interop-test.md`,
|
||
"Phase 4 — Browser harness (1.5 days)".
|
||
|
||
**Branch convention:** new branch — don't fold into the
|
||
cross-stack-test branch. Suggested name
|
||
`feat/nests-browser-interop`.
|
||
|
||
## Why a browser path
|
||
|
||
`hang-listen` validates the *wire format* against the canonical
|
||
Rust `kixelated/moq` parser. The browser path additionally
|
||
validates:
|
||
|
||
- Chromium's QUIC + WebTransport stack (different
|
||
implementation from quinn / `:quic`),
|
||
- WebCodecs `AudioDecoder` (different from libopus —
|
||
different look-ahead, different first-frame handling
|
||
behaviour, same `OpusHead` regression risk per T8/T14),
|
||
- AudioWorklet rendering timing (200 ms playback buffer +
|
||
AudioContext clock drift),
|
||
- `WT-Available-Protocols` / `WT-Protocol` ALPN negotiation
|
||
over Chromium's WebTransport — completely separate from
|
||
quinn's TLS-ALPN exchange.
|
||
|
||
The reference NostrNests web app runs on `@moq/watch` /
|
||
`@moq/publish` — this is the actual production stack the
|
||
project ships against. A wire-byte round-trip through
|
||
hang-listen alone doesn't catch a Chromium quirk that breaks
|
||
real users.
|
||
|
||
## Goal
|
||
|
||
End-to-end verify:
|
||
|
||
1. **forward** — Amethyst Kotlin speaker → headless Chromium
|
||
listener (`@moq/watch`), tone recoverable from PCM tap.
|
||
2. **reverse** — headless Chromium publisher (`@moq/publish`)
|
||
→ Amethyst Kotlin listener, tone recoverable.
|
||
3. browser-only scenarios I13 (`framesPerGroup=50` long
|
||
broadcast against `Container.Consumer`), I14 (WebCodecs
|
||
warmup × CSD-skip interaction), I15
|
||
(`WT-Available-Protocols` Chromium round-trip).
|
||
|
||
All scenarios drive the same `NativeMoqRelayHarness` from
|
||
Phase 1 — no Docker, no second relay, no fake auth sidecar.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
Test runner (Gradle :nestsClient:jvmTest)
|
||
│
|
||
┌───────────────────────┼─────────────────────────────────┐
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
┌──────────────────────┐ ┌──────────────────┐ ┌─────────────────────────────┐
|
||
│ NativeMoqRelayHarness│ │ Kotlin in-proc │ │ Browser harness │
|
||
│ (existing — Phase 1) │ │ speaker / listener│ │ nestsClient/tests/browser-interop/│
|
||
│ moq-relay subprocess │ │ via │ │ - bun static + WS server │
|
||
│ 127.0.0.1:<rand> │ │ connectNestsSpeaker │ - listen.html + listen.ts │
|
||
│ --auth-public "" │ │ connectNestsListener │ - publish.html+publish.ts │
|
||
│ --tls-generate │ │ │ │ - pcm-tap-worklet.ts │
|
||
└──────────▲───────────┘ └──────────────────┘ │ - Playwright driver │
|
||
│ WebTransport over UDP │ - PCM capture via WS back- │
|
||
│ │ channel │
|
||
│ └─────────────────────────────┘
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
## Components
|
||
|
||
### 1. `nestsClient/tests/browser-interop/` — bun + Playwright workspace
|
||
|
||
New top-level directory, mirrors the parent plan's
|
||
specification. Contents:
|
||
|
||
```
|
||
nestsClient/tests/browser-interop/
|
||
├── package.json
|
||
├── tsconfig.json
|
||
├── bun.lockb # pinned via REV file
|
||
├── REV # @moq/* npm versions
|
||
├── src/
|
||
│ ├── listen.html # static page driving Watch.Broadcast
|
||
│ ├── publish.html # static page driving Publish.Broadcast
|
||
│ ├── listen.ts # imports @moq/watch + @moq/lite + PCM tap
|
||
│ ├── publish.ts # imports @moq/publish + @moq/lite + Oscillator src
|
||
│ ├── pcm-tap-worklet.ts # AudioWorklet that posts Float32Array on every
|
||
│ │ # inputs[0] frame to the main thread
|
||
│ └── server.ts # bun static server + WebSocket back-channel
|
||
└── playwright.config.ts # Chromium-only; --enable-quic flags
|
||
```
|
||
|
||
Pin all `@moq/*` deps to the same versions `nostrnests/nests`
|
||
ships in `NestsUI-v2/package.json`. Document the rev in
|
||
`nestsClient/tests/browser-interop/REV` (parallel to
|
||
`nestsClient/tests/hang-interop/REV`).
|
||
|
||
### 2. `listen.ts` — browser listener
|
||
|
||
Mirrors NostrNests' `transport/moq-transport.ts`
|
||
`Watch.Broadcast` configuration verbatim where possible.
|
||
Reads `relay`, `path`, `jwt` (optional), `ws`, `duration`
|
||
from query params. Sets up an `AudioContext`, registers
|
||
`pcm-tap-worklet`, hooks the worklet into
|
||
`broadcast.audio.root`, posts every `inputs[0]`
|
||
`Float32Array` over the WebSocket back-channel as a binary
|
||
frame. Closes when `duration` elapses.
|
||
|
||
### 3. `publish.ts` — browser publisher
|
||
|
||
Reads same params plus `freqHz` and `channels`. Builds an
|
||
`OscillatorNode` at `freqHz` connected to a
|
||
`MediaStreamAudioDestinationNode`. Passes the resulting
|
||
MediaStream's audio track into `Publish.Broadcast`'s
|
||
`audio.source`. Closes after `duration`.
|
||
|
||
### 4. `server.ts` — bun static + WebSocket back-channel
|
||
|
||
Bun HTTP server: serves `listen.html`, `publish.html`, and
|
||
the bundled JS from `dist/`. Bun WebSocket on a separate path
|
||
(e.g. `/pcm`): receives PCM chunks from the harness page and
|
||
appends them to a file the Gradle test reads. Argv: `--port
|
||
<int>` `--out-pcm <path>`.
|
||
|
||
### 5. `playwright.config.ts` — Chromium with QUIC enabled
|
||
|
||
```ts
|
||
import { defineConfig } from '@playwright/test';
|
||
|
||
export default defineConfig({
|
||
use: {
|
||
launchOptions: {
|
||
args: [
|
||
'--enable-quic',
|
||
'--ignore-certificate-errors', // self-signed harness cert
|
||
'--enable-features=AutoplayPolicy=NoUserGestureRequired',
|
||
// For tighter cert pinning:
|
||
// '--ignore-certificate-errors-spki-list=<sha256-of-test-cert>',
|
||
],
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
`--ignore-certificate-errors` is acceptable for a test-only
|
||
Chromium instance; preferred form
|
||
`--ignore-certificate-errors-spki-list=<base64-sha256>` is
|
||
documented but optional (cert SPKI is hard to compute from
|
||
the relay's auto-generated cert without parsing).
|
||
|
||
### 6. `interopBuildBrowserHarness` Gradle task
|
||
|
||
`nestsClient/build.gradle.kts` parallel to
|
||
`interopBuildHangSidecars`:
|
||
|
||
```kotlin
|
||
val interopBuildBrowserHarness by tasks.registering(Exec::class) {
|
||
description = "bun install && bun build for the browser interop harness"
|
||
group = "interop"
|
||
workingDir = file("nestsClient/tests/browser-interop")
|
||
commandLine("bash", "-c", "bun install && bun build src/listen.ts src/publish.ts src/pcm-tap-worklet.ts --outdir dist --target browser")
|
||
inputs.files(
|
||
fileTree("nestsClient/tests/browser-interop") {
|
||
include("package.json", "bun.lockb", "src/**/*")
|
||
}
|
||
)
|
||
outputs.dir("nestsClient/tests/browser-interop/dist")
|
||
}
|
||
```
|
||
|
||
A second task installs Playwright's Chromium:
|
||
|
||
```kotlin
|
||
val interopInstallPlaywrightChromium by tasks.registering(Exec::class) {
|
||
description = "Install Playwright Chromium + dependencies"
|
||
group = "interop"
|
||
workingDir = file("nestsClient/tests/browser-interop")
|
||
commandLine("bash", "-c", "npx playwright install --with-deps chromium")
|
||
onlyIf {
|
||
// Skip if Chromium binary exists in the cache
|
||
val home = System.getProperty("user.home")
|
||
!file("$home/.cache/ms-playwright/chromium-*").exists() // glob matches if any
|
||
}
|
||
}
|
||
```
|
||
|
||
Forward to test workers:
|
||
|
||
```kotlin
|
||
tasks.withType<Test>().configureEach {
|
||
val isBrowserInterop = System.getProperty("nestsBrowserInterop") == "true"
|
||
if (isBrowserInterop) {
|
||
dependsOn(interopBuildBrowserHarness, interopInstallPlaywrightChromium)
|
||
}
|
||
systemProperty(
|
||
"nestsBrowserInteropHarnessDir",
|
||
file("nestsClient/tests/browser-interop").absolutePath,
|
||
)
|
||
System.getProperty("nestsBrowserInterop")?.let {
|
||
systemProperty("nestsBrowserInterop", it)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7. Kotlin-side `PlaywrightDriver` + `BrowserInteropTest`
|
||
|
||
Path:
|
||
`nestsClient/src/jvmTest/.../interop/native/PlaywrightDriver.kt`
|
||
(new) and
|
||
`nestsClient/src/jvmTest/.../interop/native/BrowserInteropTest.kt`
|
||
(new).
|
||
|
||
`PlaywrightDriver` shells out to `npx playwright test` (or
|
||
uses `playwright-java` from Maven Central — verify
|
||
availability at implementation time). Returns when the
|
||
harness page reports completion via a final WebSocket
|
||
message or a console log.
|
||
|
||
```kotlin
|
||
object PlaywrightDriver {
|
||
fun openListenPage(
|
||
harnessUrl: String,
|
||
relayUrl: String,
|
||
path: String,
|
||
jwt: String?,
|
||
durationSec: Int,
|
||
wsOutPcm: File,
|
||
): Process { … }
|
||
|
||
fun openPublishPage(
|
||
harnessUrl: String,
|
||
relayUrl: String,
|
||
path: String,
|
||
jwt: String?,
|
||
freqHz: Int,
|
||
channels: Int,
|
||
durationSec: Int,
|
||
): Process { … }
|
||
}
|
||
```
|
||
|
||
Each invocation:
|
||
1. Runs the bun static server on a random port (one per
|
||
test for isolation; reuses the same `:0`-bound socket
|
||
pattern as `NativeMoqRelayHarness`).
|
||
2. Spawns `npx playwright test` with `--config playwright.config.ts`
|
||
and a per-test runner that opens the right URL with the
|
||
right query params.
|
||
3. Plays through `durationSec` seconds; WS server appends PCM
|
||
frames to `wsOutPcm` as native-endian Float32 LE.
|
||
4. Returns the Process so the test can kill it cleanly.
|
||
|
||
### 8. `BrowserInteropTest` scenarios
|
||
|
||
Mirror of `HangInteropTest`'s shape. P0 scenarios per the
|
||
parent plan:
|
||
|
||
| ID | Direction | Speaker | Listener | Asserts |
|
||
|---|---|---|---|---|
|
||
| **I1 browser** | A→ref | Amethyst Kotlin | Chromium @moq/watch | FFT 440 Hz |
|
||
| **I2 browser** | both | … | … | late-join still gets tail |
|
||
| **I3 browser** | A→ref | Amethyst Kotlin | Chromium | mute window |
|
||
| **I4 browser** | both | Amethyst (stereo) | Chromium | per-channel FFT |
|
||
| **I13** | A→ref | Amethyst | Chromium | 60 s, no eviction-driven silence |
|
||
| **I14** | A→ref | Amethyst | Chromium | WebCodecs 3-frame warmup × T8 CSD-skip |
|
||
| **I15** | A→ref | Amethyst | Chromium | `WT-Protocol` matches `moq-lite-03` |
|
||
|
||
I1–I4 reuse the existing `runSpeakerToHangListen` harness
|
||
infrastructure but swap the listener subprocess from
|
||
`hang-listen` to `PlaywrightDriver.openListenPage`. The
|
||
harness already exposes `relayUrl` + relay UDP loopback;
|
||
no new harness API needed.
|
||
|
||
## Phases
|
||
|
||
Total: ~1.5 days.
|
||
|
||
### Phase 4.A — bun harness scaffold (~3 hr)
|
||
|
||
1. `bun init` in `nestsClient/tests/browser-interop/`. Pin `@moq/lite`,
|
||
`@moq/watch`, `@moq/publish`, `@moq/hang` to the versions
|
||
`nostrnests/nests` `NestsUI-v2/package.json` ships at the
|
||
time of implementation. Document in `REV`.
|
||
2. Write `listen.ts` + `pcm-tap-worklet.ts` + `listen.html`.
|
||
Mirror NostrNests' `transport/moq-transport.ts`
|
||
`Watch.Broadcast` configuration verbatim.
|
||
3. Write `publish.ts` + `publish.html` (sine source via
|
||
`OscillatorNode` → `MediaStreamAudioDestinationNode`).
|
||
4. Write `server.ts` (bun static + WebSocket back-channel,
|
||
writes PCM to a file on disk).
|
||
5. Wire `interopBuildBrowserHarness` Gradle task.
|
||
|
||
Verify by running the bun server manually + opening
|
||
`http://localhost:<port>/listen.html` in a desktop Chromium
|
||
with the `--enable-quic` + `--ignore-certificate-errors`
|
||
flags; confirm a manual moq-relay + hang-publish behind it
|
||
delivers tone.
|
||
|
||
### Phase 4.B — Playwright driver + Kotlin tests (~3 hr)
|
||
|
||
6. Add Playwright (`@playwright/test`) to the bun harness's
|
||
dev deps. Wire `interopInstallPlaywrightChromium` Gradle
|
||
task.
|
||
7. Write `PlaywrightDriver.kt` shelling out to
|
||
`npx playwright test` (or `playwright-java` if
|
||
available). Cert-pin via `--ignore-certificate-errors`
|
||
for the test-only Chromium instance.
|
||
8. Write `BrowserInteropTest.kt` with the I1 forward
|
||
scenario as the smoke test (Amethyst speaker → Chromium
|
||
listener, FFT 440 Hz on the captured PCM).
|
||
|
||
Verify green via:
|
||
```bash
|
||
./gradlew :nestsClient:jvmTest \
|
||
--tests "com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest" \
|
||
-DnestsHangInterop=true \
|
||
-DnestsBrowserInterop=true
|
||
```
|
||
|
||
### Phase 4.C — additional P0 scenarios (~3 hr)
|
||
|
||
9. I2 (late-join), I3 (mute), I4 (stereo if I4 stereo plan
|
||
has landed; else skip and unblock when stereo merges).
|
||
10. I13 (`framesPerGroup=50` long broadcast — interesting
|
||
because the Chromium path may have a different per-group
|
||
cliff threshold than `hang-listen`; this scenario likely
|
||
NEEDS `framesPerGroup=5` like the hang-listen ones).
|
||
11. I14 (WebCodecs warmup × CSD-skip): assert that with
|
||
T8's CODEC_CONFIG filter active, the browser receives
|
||
a normal decode after the standard 3-frame warmup —
|
||
no extra warmup penalty.
|
||
12. I15 (`WT-Available-Protocols` round-trip): Playwright's
|
||
`browser.newContext()` exposes the response headers;
|
||
assert `WT-Protocol` matches `moq-lite-03`.
|
||
|
||
Per-scenario commits (one per `BrowserInteropTest` test
|
||
method).
|
||
|
||
### Phase 4.D — CI integration (~1 hr)
|
||
|
||
13. Add `browser-interop` job to `.github/workflows/build.yml`
|
||
parallel to `hang-interop`. Cache
|
||
`nestsClient/tests/browser-interop/node_modules` and
|
||
`~/.cache/ms-playwright` on the bun.lockb hash.
|
||
14. Run `./gradlew :nestsClient:jvmTest -DnestsBrowserInterop=true`
|
||
on Linux runners. macOS / Windows would double the matrix
|
||
cost without catching new defects (Chromium QUIC behaviour
|
||
is consistent across platforms in the test scenarios we
|
||
care about).
|
||
|
||
## Risks + mitigations
|
||
|
||
| Risk | Mitigation |
|
||
|---|---|
|
||
| Chromium WebTransport rejects self-signed cert | Use `--ignore-certificate-errors` for test-only Chromium. Long-term, `--ignore-certificate-errors-spki-list=<sha256>` is preferable but needs SPKI extraction from the relay's auto-generated cert. |
|
||
| WebCodecs `AudioDecoder` not available in headless Chromium | WebCodecs is in stable Chromium since 94 (2021); Playwright bundles current Chromium. Verify on PR. |
|
||
| AudioWorklet on a headless context — `AudioContext.resume()` requires user gesture in some Chromium configs | Pass `--enable-features=AutoplayPolicy=NoUserGestureRequired` (already in `playwright.config.ts`) AND call `AudioContext.resume()` explicitly in the harness page before adding the audio source. |
|
||
| `@moq/watch` API changes between bun.lockb pins | Pin to specific versions matching `nostrnests/nests`. Bump deliberately. |
|
||
| Bun → Playwright integration weird on CI runners | Fall back to `node` if `bun` doesn't ship Playwright runner properly; the harness server doesn't depend on bun-specific APIs. |
|
||
| WS back-channel binary frames vs JSON: Playwright captures only stdout, not WS | Server.ts writes PCM directly to disk; the test reads the file path forwarded from the runner. No WS-from-test path. |
|
||
| Cold cache: 60s+ for `npx playwright install --with-deps chromium` | Cache `~/.cache/ms-playwright` on `package.json` hash. Document the cold cost in CI docs. |
|
||
|
||
## Definition of done
|
||
|
||
1. `nestsClient/tests/browser-interop/` directory complete with
|
||
bun + Playwright + sources building cleanly via
|
||
`interopBuildBrowserHarness`.
|
||
2. P0 scenarios green: I1 forward, I2, I3, I13, I14 (and I4
|
||
if the stereo plan landed).
|
||
3. P1 scenarios green: I15 (`WT-Protocol` round-trip).
|
||
4. CI: `browser-interop` job green on PRs and main.
|
||
5. `nestsClient/plans/2026-05-06-phase4-browser-harness-results.md`
|
||
summarising what landed, deviations, and follow-ups.
|
||
6. `nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md`
|
||
gets a "Phase 4" section appended.
|
||
7. The hang-tier scenarios (HangInteropTest) stay green when
|
||
`-DnestsBrowserInterop=true` is OFF — no regression.
|
||
|
||
## Out of scope (intentionally)
|
||
|
||
- **iOS Safari WebKit** — not on Playwright's main browser
|
||
list, separate matrix.
|
||
- **Mobile Chromium variants** (Android Chrome, Samsung
|
||
Internet) — desktop Chromium is what the production stack
|
||
ships against today.
|
||
- **Real device microphone in the publisher path** — sine
|
||
via `OscillatorNode` is enough for wire-format and decoder
|
||
correctness. Real-microphone parity is a field-test
|
||
concern.
|
||
- **`moq-lite-04` ALPN bump** — the parent plan's
|
||
out-of-scope, separate task. Pin `--client-version
|
||
moq-lite-03` (matches the existing hang-tier scenarios).
|
||
|
||
## When picking up
|
||
|
||
This plan is self-contained. The agent should:
|
||
|
||
1. Read `nestsClient/plans/2026-05-06-cross-stack-interop-test.md`
|
||
(parent) and
|
||
`nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md`
|
||
(Phase 1–3 status) for context on the harness.
|
||
2. Skim `nestsClient/src/jvmTest/.../interop/native/HangInteropTest.kt`
|
||
for the existing scenario shape — `BrowserInteropTest`
|
||
reuses `runSpeakerToHangListen`'s harness orchestration
|
||
pattern.
|
||
3. Re-clone `kixelated/moq` to `/tmp/moq` for reference:
|
||
`git clone --depth=1 https://github.com/kixelated/moq.git /tmp/moq`.
|
||
Confirm `/tmp/moq/js/watch`, `/tmp/moq/js/publish`,
|
||
`/tmp/moq/js/lite`, `/tmp/moq/js/hang` are present
|
||
(sparse checkout if needed). The browser harness's
|
||
`listen.ts` / `publish.ts` mirror that JS.
|
||
4. Verify `bun --version` ≥ 1.3 and `npx playwright
|
||
--version` available on the host. The cargo + Rust
|
||
toolchain from Phase 1 stays unchanged.
|
||
5. Implement Phase 4.A first (scaffolding + manual
|
||
verification), then 4.B (driver + first Kotlin test).
|
||
Don't proceed to scenario expansion (4.C) until 4.B is
|
||
green.
|
||
6. Each scenario commits separately. The harness setup
|
||
(4.A + 4.B) is one logical chunk.
|