diff --git a/packages/core/src/config/config.constants.ts b/packages/core/src/config/config.constants.ts index 119b554e..2e8dfd5a 100644 --- a/packages/core/src/config/config.constants.ts +++ b/packages/core/src/config/config.constants.ts @@ -12,4 +12,5 @@ export const ENV_FLAG = { expertRescue: "TSFORGE_EXPERT_RESCUE", noNearGreenCheckpoint: "TSFORGE_NO_NEAR_GREEN_CHECKPOINT", noE2eAcceptance: "TSFORGE_NO_E2E_ACCEPTANCE", + noNearGreenRotation: "TSFORGE_NO_NEAR_GREEN_ROTATION", } as const; diff --git a/packages/core/src/config/flags.ts b/packages/core/src/config/flags.ts index a768f6ec..eac07e6c 100644 --- a/packages/core/src/config/flags.ts +++ b/packages/core/src/config/flags.ts @@ -54,4 +54,13 @@ export const flags = { * without knowing a flag exists. Kill-switch TSFORGE_NO_NEAR_GREEN_CHECKPOINT=1 disables * it (A/B control / escape hatch). Thresholds N=2, M=3 from Phase 0a. */ nearGreenCheckpoint: (): boolean => !isOn(ENV_FLAG.noNearGreenCheckpoint), + /** WS-B near-green ROTATION steer (#77): when the build sits at a near-green count but the + * SPECIFIC error set rotates for several cycles (the model fixes one error and the fix spawns + * another — e.g. extracts a component → its siblings/tests are now missing), inject a + * completion-only steer ("finish the files that already have errors; don't create new + * files/modules unless the same edit adds their siblings + tests"). This is the last-mile gap + * that made green non-deterministic (build17 parked on it; build16 crossed by luck). Count-only + * WS-B can't see it (the count never sprays). DEFAULT ON, deterministic (no network); kill with + * TSFORGE_NO_NEAR_GREEN_ROTATION=1. Generic — keyed only on rule/file, no stack knowledge. */ + nearGreenRotation: (): boolean => !isOn(ENV_FLAG.noNearGreenRotation), }; diff --git a/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts b/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts index 3e81c752..41999cad 100644 --- a/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts +++ b/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts @@ -482,9 +482,14 @@ function generateNegativeBlocks( parentSeedingCode: string ): string { return entity.negatives - .map((neg) => { - // B2: Test title uses JSON.stringify to escape backticks/interpolation in neg.value - const testTitle = `negative: ${entity.id} rejects ${neg.field}=${neg.value}`; + .map((neg, index) => { + // B2: Test title uses JSON.stringify to escape backticks/interpolation in neg.value. + // The trailing [index] guarantees a UNIQUE title per negative: two negatives can share the + // same (field, value) — e.g. a required-field rule and a "must not be empty" mustNotHappen + // both yield name="" — and Playwright HARD-REJECTS a file with duplicate test titles (the + // whole spec fails to collect, ZERO tests run, and a green feature false-parks). parseStep + // matches on the "negative: " PREFIX, so the suffix does not affect result classification. + const testTitle = `negative: ${entity.id} rejects ${neg.field}=${neg.value} [${String(index)}]`; // Build valid field assignments (required non-FK fields) const validFieldAssignments = entity.fields @@ -669,6 +674,28 @@ test.describe(${JSON.stringify(name)}, () => { await authedPage.dashboard.goto(); await navigateTo${entity.id}(page); + // Capture WHY a create can silently fail: the api client throws on non-2xx and React Query + // swallows it into the mutation's error state, so the row just never appears with no clue. + // Record failed /api/ responses + console errors and append them to the assertion failure, + // turning "row not found" into the actual cause (e.g. "POST /api/v1/${entity.key} -> 422 ..."). + const apiFailures${entity.id} = []; + page.on("response", (r) => { + const u = r.url(); + const method = r.request().method(); + // Record every mutation (so we can tell "POST fired -> 201 but row didn't render" from + // "no POST fired at all"), plus any non-2xx. + const isMutation = method === "POST" || method === "PATCH" || method === "DELETE"; + if (u.includes("/api/") && (r.status() > 399 || isMutation)) { + void r + .text() + .then((b) => apiFailures${entity.id}.push(method + " " + new URL(u).pathname + " -> " + r.status() + " " + b.slice(0, 200))) + .catch(() => apiFailures${entity.id}.push(method + " " + new URL(u).pathname + " -> " + r.status())); + } + }); + page.on("console", (m) => { + if (m.type() === "error") apiFailures${entity.id}.push("console.error: " + m.text().slice(0, 200)); + }); + // Unique value → this test asserts on ITS OWN row (the shared DB accumulates rows // across tests/runs, so absolute row counts are unreliable). // Identity field gets the unique marker (type-aware: may not be the first field) @@ -694,7 +721,14 @@ ${fillFirstFieldCode} // Fill identity field with unique value // THIS test's row (identified by its unique value) appears — retries the async refetch const row = page.getByTestId("${ids.row}").filter({ hasText: unique }); - await expect(row).toBeVisible({ timeout: 10000 }); + try { + await expect(row).toBeVisible({ timeout: 10000 }); + } catch (e) { + const cause = apiFailures${entity.id}.length > 0 + ? "\\n\\nAPI activity + console during create (root-cause clue — if the POST is 2xx but the row is missing, the LIST/row render is the bug; if the POST is 4xx or absent, the mutation/form is):\\n" + apiFailures${entity.id}.join("\\n") + : "\\n\\n(No API mutation fired at all — the create form's submit is not wired to the create mutation, or the row renders without the unique text / without data-testid='${ids.row}'.)"; + throw new Error((e instanceof Error ? e.message : String(e)) + cause); + } // Verify the shown cells render the created values (scoped to THIS row only) ${rowCellAssertions} diff --git a/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts b/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts index 09803d97..7ebcccb2 100644 --- a/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts +++ b/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts @@ -514,6 +514,13 @@ export function makeBoringstackAcceptanceRunner(exec: Exec): IAcceptanceRunner { ...process.env, PLAYWRIGHT_PORT: uiPort, VITE_API_BASE: ctx.apiBase, + // The dockerized ui-dev already serves the app on uiPort; Playwright MUST reuse it, + // never start its own host dev server. The scaffold's webServer.command is `bun run dev`, + // whose preflight-host-dev.sh HARD-EXITS 1 while the container is up — so if reuse is + // off (the config gates it on !CI, and a CI=1 build env flips it off) Playwright spawns + // that, exits 1 with ZERO tests run, and the harness reads a green feature as a failed + // acceptance → park. Forcing reuse ON makes Playwright attach to the running server. + PLAYWRIGHT_REUSE_SERVER: "true", }; const result = await exec( @@ -595,6 +602,13 @@ export function makeBoringstackAcceptanceRunner(exec: Exec): IAcceptanceRunner { ...process.env, PLAYWRIGHT_PORT: uiPort, VITE_API_BASE: ctx.apiBase, + // The dockerized ui-dev already serves the app on uiPort; Playwright MUST reuse it, + // never start its own host dev server. The scaffold's webServer.command is `bun run dev`, + // whose preflight-host-dev.sh HARD-EXITS 1 while the container is up — so if reuse is + // off (the config gates it on !CI, and a CI=1 build env flips it off) Playwright spawns + // that, exits 1 with ZERO tests run, and the harness reads a green feature as a failed + // acceptance → park. Forcing reuse ON makes Playwright attach to the running server. + PLAYWRIGHT_REUSE_SERVER: "true", }; const result = await exec( diff --git a/packages/core/src/loop/boringstack/acceptance/testid-contract.ts b/packages/core/src/loop/boringstack/acceptance/testid-contract.ts index 9ea96911..56c88899 100644 --- a/packages/core/src/loop/boringstack/acceptance/testid-contract.ts +++ b/packages/core/src/loop/boringstack/acceptance/testid-contract.ts @@ -84,7 +84,7 @@ ${entity.fields.map((f) => ` - \`data-testid="${ids.field(f.name)}"\` for the " ${entity.shows.map((s) => ` - \`data-testid="${ids.rowCell(s)}"\` for the "${s}" column`).join("\n")} - **Row edit button**: \`data-testid="${ids.rowEdit}"\` — the edit action on each row - **Row delete button**: \`data-testid="${ids.rowDelete}"\` — the delete action on each row -- **Delete confirmation dialog**: \`data-testid="${ids.confirmDelete}"\` — the confirmation prompt when deleting a record +- **Delete confirm button**: \`data-testid="${ids.confirmDelete}"\` — put this on the BUTTON that actually performs the deletion (the one whose click fires the delete mutation), NOT on the dialog container/overlay. End-to-end acceptance CLICKS this testid to confirm the delete; if it sits on a wrapper \`