Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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*
28 changes: 28 additions & 0 deletions packages/core/scripts/headless-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ export function resolveWorkspaceDir(dir: string): string {
}
}

/**
* The autonomous builder defaults expert rescue ON so a stalled feature is
* handed to the configured `capabilities.expert` model instead of parking.
* Explicit env wins: a caller that already set TSFORGE_EXPERT_RESCUE (including
* "0" to opt out) keeps their value; only an unset flag defaults to "1".
*/
export function resolveExpertRescueFlag(current: string | undefined): string {
return current ?? "1";
}

/**
* Apply the expert-rescue default onto an environment object (the wiring around
* {@link resolveExpertRescueFlag}). Mutates `env.TSFORGE_EXPERT_RESCUE` in place so
* the autonomous builder defaults it on while an explicit value is preserved.
*/
export function applyExpertRescueDefault(
env: Record<string, string | undefined>
): void {
env.TSFORGE_EXPERT_RESCUE = resolveExpertRescueFlag(
env.TSFORGE_EXPERT_RESCUE
);
}

/**
* Parse headless-build command-line arguments into typed fields.
* Flags (--log-file, --plan) and positionals (prompt, dir) can appear in any order.
Expand Down Expand Up @@ -352,6 +375,11 @@ async function main(): Promise<void> {
process.env.VALKEY_HOST ??= "localhost";
process.env.VALKEY_PORT ??= String(valkeyPort);

// This is a real autonomous builder: enable expert rescue by default so a
// stalled feature is handed to the configured `capabilities.expert` model
// instead of parking. Explicit env wins (set TSFORGE_EXPERT_RESCUE=0 to opt out).
applyExpertRescueDefault(process.env);

process.stdout.write(
`isolated ports → postgres ${String(pgPort)} · api ${String(apiPort)} · valkey ${String(valkeyPort)}\n`
);
Expand Down
219 changes: 178 additions & 41 deletions packages/core/src/loop/boringstack/acceptance/e2e-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,121 @@ function generateParentSeedingCode(
return codeBlocks.join("\n");
}

/**
* Render a field value to a code literal string.
* For numeric types (exact match), renders as a bare number or falls back to string for NaN.
* For boolean types (exact match), renders as a bare boolean.
* For everything else, renders as a JSON string.
* Complexity: ≤ 20 (normalized type match with fallback).
*/
function renderFieldValue(field: { type: string; valid: string }): string {
const normalized = field.type.toLowerCase().trim();

// Numeric types: exact match against known numeric type names
const numericTypes = new Set([
"number",
"integer",
"int",
"float",
"double",
"decimal",
"numeric",
]);

if (numericTypes.has(normalized)) {
const num = Number(field.valid);

// Guard NaN → fall back to JSON string
if (Number.isNaN(num)) {
return JSON.stringify(field.valid);
}

return String(num);
}

// Boolean types: exact match against known boolean type names
const booleanTypes = new Set(["boolean", "bool"]);

if (booleanTypes.has(normalized)) {
return field.valid.trim().toLowerCase() === "true" ? "true" : "false";
}

// Everything else: JSON 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().
Expand Down Expand Up @@ -348,61 +463,88 @@ function generateRowCellAssertions(

/**
* Generate negative test blocks.
* Safely interpolates field names, invalid values, and entity name using JSON.stringify.
* B2: Ensures test title and assertions use JSON.stringify for injection-safe escaping.
* 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. Record initial row count before creating the form
* 2. Open create form and emit parentSeedingCode (so FK variables are declared)
* 3. Fill all fields validly
* 4. Override ONLY the target field with invalid value
* 5. Submit and reload
* 6. Assert row count did NOT increase — the invalid record was rejected and not persisted
* 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 via renderInvalidOverride)
* 4. POST directly to /api/v1/<entity> and assert a 400 or 422 response (validation error codes)
*
* This approach is robust: it doesn't depend on visible error elements and reliably
* distinguishes API rejection from form validation failures.
* This deterministic API-level check proves validation is enforced without depending on
* browser form interaction or visible error elements. Only accepting 400/422 prevents
* false-positive passes from 401/403/404/409 auth/routing/conflict errors.
*/
function generateNegativeBlocks(
entity: IEntityAcceptance,
ids: ReturnType<typeof testIdsFor>,
fieldFillSteps: string,
parentSeedingCode: string
): string {
return entity.negatives
.map((neg) => {
const fieldTestId = ids.field(neg.field);
// B2: Test title uses JSON.stringify to escape backticks/interpolation in neg.value
const testTitle = `negative: ${entity.id} rejects ${neg.field}=${neg.value}`;

return ` test(${JSON.stringify(testTitle)}, async ({ page, authedPage }) => {
await authedPage.dashboard.goto();
await navigateTo${entity.id}(page);
// Build valid field assignments (required non-FK fields)
const validFieldAssignments = entity.fields
.filter((f) => !f.optional)
.filter((f) => !entity.parents.some((p) => p.fkField === f.name))
.map((f) => ` ${f.name}: ${renderFieldValue(f)}`);

// Record initial row count before attempting to create an invalid record
const rowsBefore = await page.getByTestId("${ids.row}").count();
// Build FK field assignments
const fkFieldAssignments = entity.parents.map(
(p) => ` ${p.fkField}: ${p.key}Id`
);

// Open create form
await page.getByTestId("${ids.create}").click();
await page.getByTestId("${ids.form}").waitFor({ state: "visible", timeout: 5000 });
// Combine all assignments, filtering out empties
const allAssignments = [
...validFieldAssignments,
...fkFieldAssignments,
].filter((s) => s.length > 0);
const payloadFields = allAssignments.join(",\n");

// 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
);

// Emit parent seeding code so FK variables (e.g., companyId) are declared
${parentSeedingCode}
// 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)`;

// Fill all fields with valid values
${fieldFillSteps}
return ` test(${JSON.stringify(testTitle)}, async ({ page, authedPage }) => {
await authedPage.dashboard.goto();

// Override the target field with the invalid value (clear first, then fill)
await page.getByTestId("${fieldTestId}").clear();
await page.getByTestId("${fieldTestId}").fill(${JSON.stringify(neg.value)});
${parentSeedingCode}

// Submit with invalid input
await page.getByTestId("${ids.submit}").click();
const apiBase = process.env.VITE_API_BASE || "http://localhost:7331";
const cookieHeader = await page
.context()
.cookies()
.then((cs) => cs.map((c) => \`\${c.name}=\${c.value}\`).join("; "));

// Reload to ensure the invalid record would persist if accepted by the backend
await page.reload();
await page.waitForURL(/\\/${entity.key}/);
// A payload that is valid EXCEPT for the field under test (overridden with the invalid value).
const payload: Record<string, unknown> = {
${payloadFields}
};
payload[${JSON.stringify(neg.field)}] = ${overrideValue};

// Assert: the row count did NOT increase — the invalid record was rejected
const rowsAfter = await page.getByTestId("${ids.row}").count();
await expect(rowsAfter).toBe(rowsBefore, { timeout: 5000 });
const res = await page.request.post(\`\${apiBase}/api/v1/${entity.key}\`, {
headers: { "Content-Type": "application/json", Cookie: cookieHeader },
data: payload,
});

// Invalid input MUST be rejected with a validation error (400 or 422), not any 4xx.
// 401/403/404/409 are auth/routing/conflict errors that do not prove field validation.
expect(
[400, 422].includes(res.status()),
${JSON.stringify(errorMsg)} + \`, got \${res.status()}\`
).toBe(true);
});
`;
})
Expand Down Expand Up @@ -442,12 +584,7 @@ export function generateEntitySpec(
entity.id,
spec
);
const negativeBlocks = generateNegativeBlocks(
entity,
ids,
fieldFillSteps,
parentSeedingCode
);
const negativeBlocks = generateNegativeBlocks(entity, parentSeedingCode);

// FIX E/FIX 3: Type-aware unique identity value
// Find the first field with a string/text type (NOT email by type or name) for the unique marker
Expand Down
Loading