diff --git a/.gitignore b/.gitignore index 4c841288..4eb8e772 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ packages/core/scripts/gen-plan.ts # harness-generated acceptance specs + playwright artifacts (never commit) apps/*/e2e/_acceptance/ test-results/ + +# local build/panel logs (never commit) +/scratchpad-*.log* diff --git a/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts b/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts index 7aa136f8..3e81c752 100644 --- a/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts +++ b/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts @@ -270,6 +270,78 @@ function renderFieldValue(field: { type: string; valid: string }): string { return JSON.stringify(field.valid); } +/** + * Render an invalid override value (for negative tests) to a code literal string. + * Type-aware rendering: tests the constraint with bare types (not strings). + * Rules: + * - empty string "" → stays as "" (tests required-empty constraint) + * - numeric field + token round-trips exactly (`String(Number(value)) === value`) → + * bare number (tests numeric constraint); NON-canonical tokens ("01", "1e5", "0x10", + * " 5 ", huge→Infinity) → JSON string (Number() would mutate them / bare "01" is an + * octal SyntaxError in strict mode) + * - boolean field + value is EXACTLY "true"/"false" → bare boolean (no trim/case-fold) + * - boolean field + other token (incl. " false ", "FALSE") → JSON string (type-rejection) + * - everything else → JSON string (default fallback) + * Complexity: ≤ 20 (three type checks with normalized matching). + */ +function renderInvalidOverride(field: { type: string }, value: string): string { + // Empty string: stay as empty string literal to test required-empty constraint + if (value === "") { + return '""'; + } + + const normalized = field.type.toLowerCase().trim(); + + // Numeric types: bare number for finite values (tests numeric constraint) + const numericTypes = new Set([ + "number", + "integer", + "int", + "float", + "double", + "decimal", + "numeric", + ]); + + if (numericTypes.has(normalized)) { + // Emit a bare number ONLY when the token ROUND-TRIPS exactly through Number: + // `String(Number(value)) === value`. This is the true canonical-literal test — + // it accepts "-1"/"1.5" (constraint probes) but rejects tokens Number() would + // MUTATE or that are invalid JS: "01"/"00" (would be an octal SyntaxError in the + // strict-mode spec, and Number("01")→1), "1e5"→100000, "0x10"→16, " 5 "→5, and + // huge digit strings → Infinity. Those fall through to a raw string, which + // exercises type-rejection instead of silently testing a different value. + const asNumber = Number(value); + + if (Number.isFinite(asNumber) && String(asNumber) === value) { + return value; + } + + return JSON.stringify(value); + } + + // Boolean types: bare boolean ONLY for the exact tokens "true"/"false". Any other + // token — including " false " or "FALSE" — is sent as a raw string so we test the + // EXACT value the plan specified (type/value rejection), never a folded valid bool. + const booleanTypes = new Set(["boolean", "bool"]); + + if (booleanTypes.has(normalized)) { + if (value === "true") { + return "true"; + } + + if (value === "false") { + return "false"; + } + + // Non-boolean token (e.g., "notabool") for a boolean field: JSON string to test type-rejection + return JSON.stringify(value); + } + + // Everything else: JSON string + return JSON.stringify(value); +} + /** * Generate field fill steps for form inputs. * FIX D: Type-aware field fills (select/checkbox/date/number) instead of blanket .fill(). @@ -392,13 +464,13 @@ function generateRowCellAssertions( /** * Generate negative test blocks. * B2: Ensures test title and assertions use JSON.stringify for injection-safe escaping. - * B3: Overrides the invalid field using renderFieldValue (type-aware), except + * B3: Overrides the invalid field using renderInvalidOverride (type-aware), except * required-empty "" stays as empty string to test missing-required constraint. * * Negatives work by: * 1. Authenticate and seed parent entities via API * 2. Build a payload with all required non-FK fields (valid values) + FK fields (seeded ids) - * 3. Override the target field with the invalid value (type-rendered, or "" for required-empty) + * 3. Override the target field with the invalid value (type-rendered via renderInvalidOverride) * 4. POST directly to /api/v1/ and assert a 400 or 422 response (validation error codes) * * This deterministic API-level check proves validation is enforced without depending on @@ -432,13 +504,15 @@ function generateNegativeBlocks( ].filter((s) => s.length > 0); const payloadFields = allAssignments.join(",\n"); - // B3: Render the override value type-aware, except required-empty "" stays as "" - const targetFieldType = - entity.fields.find((f) => f.name === neg.field)?.type ?? "string"; - const overrideValue = - neg.value === "" - ? '""' - : renderFieldValue({ type: targetFieldType, valid: neg.value }); + // B3: Render the override value type-aware via renderInvalidOverride to test the constraint + // with bare types (not strings). This ensures invalid numeric values like "-1" render as + // bare -1 (testing the constraint), and invalid booleans like "notabool" render as the + // string (testing type-rejection). + const fieldDef = entity.fields.find((f) => f.name === neg.field); + const overrideValue = renderInvalidOverride( + fieldDef ?? { type: "string" }, + neg.value + ); // B2: Build error message safely without backtick interpolation of plan data const errorMsg = `expected ${entity.key} to reject ${neg.field} with a validation error (400/422)`; diff --git a/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts b/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts index 40da0772..09803d97 100644 --- a/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts +++ b/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts @@ -144,44 +144,6 @@ function isInfraError(text: string): boolean { return infraIndicators.some((indicator) => text.includes(indicator)); } -/** - * Extract error messages from Playwright report JSON stdout. - * Parses the JSON and collects top-level report.errors[].message (not per-test errors). - * Per-test assertion errors must NOT drive suite-wide infra classification. - * Returns empty string if no errors found or JSON is invalid. - */ -function extractReportErrorText(stdout: string): string { - const errors: string[] = []; - - if (stdout.trim().length === 0) { - return ""; - } - - let parsed: unknown; - - try { - parsed = JSON.parse(stdout); - } catch { - return ""; - } - - if (!isPlaywrightReport(parsed)) { - return ""; - } - - // Collect top-level report errors ONLY - // Global setup/launch failures land here; per-test assertion errors do not. - if (Array.isArray(parsed.errors)) { - for (const err of parsed.errors) { - if ("message" in err && typeof err.message === "string") { - errors.push(err.message); - } - } - } - - return errors.join("\n"); -} - /** * Extract the error message from a test result, if present. */ @@ -327,8 +289,14 @@ function classifyNonzeroExit( shouldRetry: boolean; } { // Combine stderr with extracted report errors to form the complete error text + // Extract top-level report errors from the already-parsed report (no re-parse) const reportErrorText = - parsedReport !== null ? extractReportErrorText(result.stdout) : ""; + parsedReport !== null && Array.isArray(parsedReport.errors) + ? parsedReport.errors + .map((err) => (typeof err.message === "string" ? err.message : "")) + .filter((msg) => msg.length > 0) + .join("\n") + : ""; const combinedErrorText = result.stderr + (reportErrorText !== "" ? "\n" + reportErrorText : ""); @@ -385,7 +353,10 @@ function classifyNonzeroExit( /** * Process a single exec result for entity acceptance testing. * Parses Playwright JSON and extracts results. - * Returns: { outcome, shouldRetry } where outcome is the final result or undefined to continue/retry. + * Returns: { outcome, shouldRetry, parseResult, parsed } where outcome is the final result or undefined to continue/retry. + * parsed is non-null only when JSON was successfully parsed (even if no specs matched). + * This distinction (null vs []) lets the runner preserve stale results from an earlier + * successful parse when a later attempt has unparseable JSON. */ export function processExecResult( result: { code: number; stdout: string; stderr: string }, @@ -395,6 +366,7 @@ export function processExecResult( outcome?: IAcceptanceOutcome; shouldRetry: boolean; parseResult: IAcceptanceResult[]; + parsed: IPlaywrightReportType | null; } { // Parse JSON and extract acceptance results const { parsed: parsedReport, results: parseResult } = parsePlaywrightResults( @@ -407,6 +379,7 @@ export function processExecResult( outcome: summarize(parseResult, requiredSteps), shouldRetry: false, parseResult, + parsed: parsedReport, }; } @@ -414,6 +387,7 @@ export function processExecResult( return { ...classifyNonzeroExit(result, parsedReport, parseResult), parseResult, + parsed: parsedReport, }; } @@ -557,20 +531,20 @@ export function makeBoringstackAcceptanceRunner(exec: Exec): IAcceptanceRunner { } ); - const { outcome, shouldRetry, parseResult } = processExecResult( - result, - entity, - requiredSteps - ); + const { outcome, shouldRetry, parseResult, parsed } = + processExecResult(result, entity, requiredSteps); if (outcome !== undefined) { return outcome; } // Preserve parsed results even if classified as infra (for diagnostics). - // Reuse the parse from processExecResult — do not re-parse the same stdout. - // parseResult is always an array (may be empty if JSON was invalid) - lastResults = parseResult; + // Distinguish unparseable (parsed === null) from valid-empty (parsed !== null, parseResult === []): + // - Unparseable (null): keep the previous lastResults from an earlier successful parse + // - Valid-empty (parsed !== null): overwrite lastResults with the latest valid parse (even if empty) + if (parsed !== null) { + lastResults = parseResult; + } lastError = result.stderr; diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index c7a4d538..154a2126 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -103,6 +103,21 @@ export const APP_SCHEMA_FILE = * instructed (refinePrompt) to only ADD its feature's keys, never touch others'. */ export const LOCALE_GLOB = "apps/ui/src/lib/i18n/locales/**"; +/** The shared app sidebar navigation component. Features are unreachable (fail browser + * acceptance tests) if the model doesn't register a NavLink for the feature in the + * sidebar. ADD-ONLY shared file: the worker adds only its own NavLink, accepted like + * APP_SCHEMA_FILE/LOCALE_GLOB (filesystem scope grants write, differential gate + judge + * catch any regression to another feature's nav). It's instructed (refinePrompt) to ADD + * ONLY its feature's link, never modify another feature's entry or remove entries. */ +export const APP_SIDEBAR_FILE = + "apps/ui/src/components/core/AppSidebar/AppSidebar.tsx"; + +/** The shared router configuration file. A feature without a route entry is unreachable + * (knip flags the page as unused). ADD-ONLY shared file: the worker adds only its own + * route, same trade-off as APP_SIDEBAR_FILE. It's instructed (refinePrompt) to ADD ONLY + * its feature's route entry, never modify another feature's route. */ +export const APP_ROUTES_FILE = "apps/ui/src/app/router/routes.tsx"; + export function scopeFor(name: string): string[] { const camel = toCamelCase(name); @@ -116,6 +131,11 @@ export function scopeFor(name: string): string[] { // Same story for i18n: any UI string is a locale key, and the keys live in // shared locale files — the model must be able to add the keys it references. LOCALE_GLOB, + // The sidebar and router are shared UI files. A feature is unreachable (fails + // browser acceptance) unless the model adds a NavLink to the sidebar and a route + // to the router. Add-only: the model may ADD its feature's entry, never modify others'. + APP_SIDEBAR_FILE, + APP_ROUTES_FILE, ]; } diff --git a/packages/core/src/loop/boringstack/refine-prompt.ts b/packages/core/src/loop/boringstack/refine-prompt.ts index c0910206..f5a1df2c 100644 --- a/packages/core/src/loop/boringstack/refine-prompt.ts +++ b/packages/core/src/loop/boringstack/refine-prompt.ts @@ -178,6 +178,27 @@ Go through \`tests/helpers/db\` for db/schema — never import \`drizzle-orm\` o --- +## BoringStack UI component conventions — write it RIGHT the first time + +The UI eslint (react-component-architecture) is strict; these rules cause the most churn. Copy the scaffold's gate-green example \`apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.tsx\` — a list with per-row actions, exactly the shape your edit/delete rows need. + +**No arrow functions or .bind in JSX props (\`jsx-no-bind\` — the #1 churn source).** \`onClick={() => onEdit(row.id)}\` is REJECTED. For per-row actions add a curried helper in \`${camel}.utils.ts\`: +\`\`\`ts +export const makeIdHandler = + (fn: (id: string) => void) => (id: string) => () => fn(id); +\`\`\` +then in the component body \`const editHandler = makeIdHandler(onEdit);\` (and \`deleteHandler\`), and in JSX \`onClick={editHandler(row.id)}\`. The prop is a call expression, not a literal arrow, so the rule passes. This curried-helper pattern is the scaffold's own gate-green convention (see JoinRequestsPage example). For a fully stable handler, a per-row child component with a useCallback is an alternative, but the call-expression helper is the established pattern here. For a zero-arg handler pass a named function reference (\`onClick={handleSubmit}\`), never an inline arrow. + +**Extract computed lists.** Build the rows as a const in the body (\`const renderRows = items.map((row) => (…));\`) or a hook — never inline a complex \`.map()\` with logic in the returned JSX ("Extract this computation into a hook"). + +**One concern per file.** The \`.tsx\` is presentational only. Data-fetching hooks live in the generated PascalCase \`${feature.id}.queries.ts\` (list/fetch queries) and \`${feature.id}.mutations.ts\` (create/update/delete mutations) — FILL THESE, do NOT create parallel \`${camel}.hooks.ts\` files. Component view-state hooks go in a co-located \`.hooks.ts\` next to the component. Pure helpers in \`${camel}.utils.ts\` / \`.utils.ts\`. Types in \`${camel}.types.ts\`. NEVER put JSX in a \`.ts\` file (JSX only compiles in \`.tsx\`; a \`.ts\` with \`\` throws "Parsing error: '>' expected"). Never mix a hook + component + type in one module. + +**Every visible string via \`t("features.${camel}.")\`** — no hardcoded JSX text (button labels like "Edit"/"Delete" included). + +**Make the feature reachable.** Add a sidebar link to \`apps/ui/src/components/core/AppSidebar/AppSidebar.tsx\` (add your feature's entry to the \`APP_SIDEBAR_NAV_ITEMS\` array, following the existing format) and register its route in \`apps/ui/src/app/router/routes.tsx\` (add a route pointing at your feature's page component). Both files are in your editable scope — ADD ONLY your feature's entries, never modify another feature's link/route (same rule as the shared schema + locale files). A feature missing from the sidebar or router is unreachable and fails browser acceptance. + +--- + ## Domain-Fill Instructions - **Persist for real**: every domain field must be a real column on the \`${camel}\` table (see Persistence above) that the service inserts/selects/updates via Drizzle. NEVER hold a field only in memory to satisfy a test — that stores nothing and is a failed implementation. @@ -192,10 +213,12 @@ Go through \`tests/helpers/db\` for db/schema — never import \`drizzle-orm\` o ## Freeze -⚠️ **FREEZE**: Only the files above for the **${feature.id}** resource are editable — that includes adding your entity's columns to the \`${camel}\` table in the app schema. All other files are locked. Do not modify: +⚠️ **FREEZE**: Only the files above for the **${feature.id}** resource are editable. The shared app schema, locale files, AppSidebar.tsx, and routes.tsx are the explicit ADD-ONLY exceptions: the model may edit them only to add entries for this feature, never to modify other features' entries or remove them. All other files are locked. Do not modify: - Any table OTHER than \`${camel}\` in the app schema; no migrations +- Any locale namespace OTHER than \`features.${camel}\` in the locale files +- Any sidebar link OTHER than your feature's link; do not modify other features' links +- Any route OTHER than your feature's route; do not modify other features' routes - Root configuration files -- Routes wiring (already done for this resource) - Other resources' files If you need to make a change elsewhere, the build has already locked it. Rebase this feature once it passes the gate. diff --git a/packages/core/tests/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index 970fb5ab..d842b231 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -356,6 +356,19 @@ describe("scopeFor", () => { expect(scope).toContain(APP_SCHEMA_FILE); expect(scope).toContain(LOCALE_GLOB); }); + + test("includes shared sidebar and router files (add-only, not edit existing entries)", () => { + const scope = scopeFor("Company"); + + // The sidebar and router are shared UI files. A feature is unreachable (fails + // browser acceptance tests) if the model doesn't register a NavLink for the feature + // in the sidebar and a route entry in the router. Add-only: the model may ADD its + // feature's entry, never modify another feature's entry or remove entries. + expect(scope).toContain( + "apps/ui/src/components/core/AppSidebar/AppSidebar.tsx" + ); + expect(scope).toContain("apps/ui/src/app/router/routes.tsx"); + }); }); describe("rescueFileFor", () => { diff --git a/packages/core/tests/boringstack-e2e-generator.test.ts b/packages/core/tests/boringstack-e2e-generator.test.ts index 0c0deb97..7a3e67d5 100644 --- a/packages/core/tests/boringstack-e2e-generator.test.ts +++ b/packages/core/tests/boringstack-e2e-generator.test.ts @@ -1757,7 +1757,7 @@ describe("FIX 1, 2, 3: negative test hardening (400/422 only, type-correct paylo expect(spec).not.toContain("${code}injection`"); }); - test("B3: type-render invalid override as bare number (not string)", () => { + test("B3: canonical numeric invalid renders as a bare number (tests the constraint)", () => { const entityWithNumericNegative: IEntityAcceptance = { id: "Product", key: "product", @@ -1789,11 +1789,58 @@ describe("FIX 1, 2, 3: negative test hardening (400/422 only, type-correct paylo const spec = generateEntitySpec(entityWithNumericNegative); - // B3: Invalid override for numeric field should be bare -1 (not "-1" string) + // B3: Invalid override for numeric field renders as bare number (not string) + // to exercise the numeric range/constraint, not type-rejection. + // "-1" is finite, so it renders as bare -1, testing the constraint. expect(spec).toContain('payload["stock"] = -1'); }); - test("B3: type-render invalid override as bare boolean (not string)", () => { + test("B3: NON-canonical numeric invalid stays a raw string (not mutated by Number)", () => { + const entity: IEntityAcceptance = { + id: "Product", + key: "product", + nav: "Products", + fields: [ + { + name: "name", + type: "string", + optional: false, + valid: "P1", + invalid: [], + }, + { + name: "stock", + type: "integer", + optional: false, + valid: "10", + invalid: [], + }, + ], + shows: ["name", "stock"], + screens: ["list", "form"], + parents: [], + // "01" does NOT round-trip (Number("01")→1, String(1)!=="01") and a bare `01` + // would be an OCTAL SyntaxError in the strict-mode spec — so it must stay a raw + // string, testing type-rejection rather than corrupting to a valid `1`. + negatives: [ + { + field: "stock", + value: "01", + why: "leading-zero token is not a valid integer input", + }, + ], + acceptanceCheck: "create a product", + }; + + const spec = generateEntitySpec(entity); + + expect(spec).toContain('payload["stock"] = "01"'); + // Must NOT emit a bare octal literal (SyntaxError) or a mutated value. + expect(spec).not.toContain('payload["stock"] = 01'); + expect(spec).not.toContain('payload["stock"] = 1;'); + }); + + test("B3: type-render invalid boolean token as string (testing type-rejection)", () => { const entityWithBooleanNegative: IEntityAcceptance = { id: "Feature", key: "feature", @@ -1825,8 +1872,46 @@ describe("FIX 1, 2, 3: negative test hardening (400/422 only, type-correct paylo const spec = generateEntitySpec(entityWithBooleanNegative); - // B3: Invalid override for boolean field with invalid value should render as false (fallback) - // "notabool" is not "true", so it becomes false + // B3: Invalid boolean token (not "true"/"false") renders as JSON string + // to exercise type-rejection. "notabool" is not a valid boolean, so it + // renders as the string "notabool" to test the type constraint. + expect(spec).toContain('payload["active"] = "notabool"'); + }); + + test("B3: type-render valid boolean value as bare boolean", () => { + const entityWithValidBooleanNegative: IEntityAcceptance = { + id: "Feature", + key: "feature", + nav: "Features", + fields: [ + { + name: "name", + type: "string", + optional: false, + valid: "Feature1", + invalid: [], + }, + { + name: "active", + type: "bool", + optional: false, + valid: "true", + invalid: [], + }, + ], + shows: ["name", "active"], + screens: ["list", "form"], + parents: [], + negatives: [ + { field: "active", value: "false", why: "active must be true" }, + ], + acceptanceCheck: "create a feature", + }; + + const spec = generateEntitySpec(entityWithValidBooleanNegative); + + // B3: Valid boolean value "false" renders as bare false + // to exercise the boolean value constraint (not type-rejection). expect(spec).toContain('payload["active"] = false'); }); diff --git a/packages/core/tests/boringstack-e2e-runner.test.ts b/packages/core/tests/boringstack-e2e-runner.test.ts index 2b8c8b59..0a80a734 100644 --- a/packages/core/tests/boringstack-e2e-runner.test.ts +++ b/packages/core/tests/boringstack-e2e-runner.test.ts @@ -1016,3 +1016,131 @@ test("FIX A: parseStep recognizes all chain-create titles", async () => { expect(outcome.results.length).toBe(3); expect(outcome.results.every((r) => r.step === "create")).toBe(true); }); + +test("FIX 1b: runner preserves earlier parsed result when later attempt is unparseable", async () => { + let attemptCount = 0; + + const fakeExec: Exec = async () => { + attemptCount++; + + if (attemptCount === 1) { + // First attempt: parseable JSON with results (infra error in stderr) + return { + code: 1, + stdout: JSON.stringify({ + suites: [ + { + title: "e2e/company.spec.ts", + specs: [ + { + title: "create Company: form fill, submit, row appears", + ok: false, + tests: [ + { + results: [ + { + status: "failed", + error: { message: "Row did not appear" }, + }, + ], + }, + ], + }, + ], + }, + ], + stats: { expected: 1, unexpected: 1, flaky: 0, skipped: 0 }, + errors: [], + }), + stderr: "ECONNREFUSED: connection timeout", + }; + } + + if (attemptCount === 2) { + // Second attempt: still infra error, unparseable (empty stdout) + return { + code: 1, + stdout: "", + stderr: "ECONNREFUSED: infrastructure failure", + }; + } + + // Third attempt: still unparseable + return { + code: 1, + stdout: "", + stderr: "ECONNREFUSED: final infra failure", + }; + }; + + const runner = makeBoringstackAcceptanceRunner(fakeExec); + const ctx = createTestCtx(); + + const outcome = await runner.run(testEntity, ctx); + + // Must be classified as infra error after 3 attempts + expect(outcome.infraError).toBeDefined(); + // CRITICAL: results from the first (parseable) attempt must be preserved + expect(outcome.results.length).toBeGreaterThan(0); + expect(outcome.results[0]?.step).toBe("create"); + expect(outcome.results[0]?.detail).toBe("Row did not appear"); +}); + +test("FIX 1b: runner overwrites earlier result when later attempt is valid-empty []", async () => { + let attemptCount = 0; + + const fakeExec: Exec = async () => { + attemptCount++; + + if (attemptCount === 1) { + // First attempt: parseable JSON with one result + infra error in stderr + return { + code: 1, + stdout: JSON.stringify({ + suites: [ + { + title: "e2e/company.spec.ts", + specs: [ + { + title: "create Company: form fill, submit, row appears", + ok: true, + tests: [{ results: [{ status: "passed" }] }], + }, + ], + }, + ], + stats: { expected: 1, unexpected: 0, flaky: 0, skipped: 0 }, + errors: [], + }), + stderr: "ECONNREFUSED: connection timeout", + }; + } + + // Second attempt: parseable JSON but no matching test specs (empty results, still infra error) + return { + code: 1, + stdout: JSON.stringify({ + suites: [ + { + title: "e2e/company.spec.ts", + specs: [], + }, + ], + stats: { expected: 0, unexpected: 0, flaky: 0, skipped: 0 }, + errors: [], + }), + stderr: "ECONNREFUSED: connection still failing", + }; + }; + + const runner = makeBoringstackAcceptanceRunner(fakeExec); + const ctx = createTestCtx(); + + const outcome = await runner.run(testEntity, ctx); + + // Must be classified as infra error + expect(outcome.infraError).toBeDefined(); + // CRITICAL: results must be the LATEST valid-empty [] parse (empty array from second attempt) + // NOT the earlier result with "create" from first attempt + expect(outcome.results.length).toBe(0); +}); diff --git a/packages/core/tests/boringstack-refine-prompt.test.ts b/packages/core/tests/boringstack-refine-prompt.test.ts index 8efa9534..f3dc6ed7 100644 --- a/packages/core/tests/boringstack-refine-prompt.test.ts +++ b/packages/core/tests/boringstack-refine-prompt.test.ts @@ -430,4 +430,51 @@ describe("refinePrompt", () => { expect(prompt).toContain("Do NOT run the gate through the shell"); expect(prompt).not.toContain("Do NOT run the gate yourself"); }); + + it("front-loads BoringStack UI component conventions (makeIdHandler list-row idiom, module split, api-client typed unwrap)", () => { + const feature: IFeature = { + id: "Invoice", + desc: "Customer billing record", + passes: false, + attempts: 0, + }; + + const prompt = refinePrompt(feature); + + // Must teach the UI conventions section. + expect(prompt).toContain("BoringStack UI component conventions"); + // Must teach the #1 jsx-no-bind churn source. + expect(prompt).toContain("jsx-no-bind"); + // Must teach the makeIdHandler curried list-row idiom to avoid arrow functions in JSX. + expect(prompt).toContain("makeIdHandler"); + // Must forbid JSX in .ts files (JSX only compiles in .tsx). + expect(prompt).toContain("JSX only compiles in `.tsx`"); + expect(prompt).toContain("Parsing error: '>' expected"); + // Must teach module separation (component in .tsx, hooks in .hooks.ts, helpers in .utils.ts). + expect(prompt).toContain(".hooks.ts"); + expect(prompt).toContain(".utils.ts"); + expect(prompt).toContain(".types.ts"); + // (api-client data-fetching idiom is taught by the existing data-fetching + // section, not the UI-component conventions — no duplicate/contradictory copy here.) + }); + + it("teaches the reachability contract: register nav + route, ADD-ONLY", () => { + const feature: IFeature = { + id: "Company", + desc: "A business the sales team works with", + passes: false, + attempts: 0, + }; + + const prompt = refinePrompt(feature); + + // Must instruct wiring the feature into BOTH the sidebar and the router so it is reachable. + expect(prompt).toContain("AppSidebar"); + expect(prompt).toContain("routes.tsx"); + // The sidebar registration is via the scaffold's nav-items array (which renders + // the nav- testid the acceptance gate navigates by). + expect(prompt).toContain("APP_SIDEBAR_NAV_ITEMS"); + // Must carry the add-only boundary for those shared files. + expect(prompt).toContain("ADD ONLY"); + }); });