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
60 changes: 46 additions & 14 deletions packages/core/src/loop/boringstack/acceptance/e2e-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,11 @@ function emitSpecParentSeed(
function generateParentSeedingCode(
parents: IParentRef[],
entityId: string,
spec?: IAcceptanceSpec
spec?: IAcceptanceSpec,
// Prepend the "seed before navigate" rationale. Only the form-driven tests navigate to the
// child page and select from its async <select>; the API-only negative blocks POST directly
// and never render a select, so the <select> note would be misleading there → opt-out.
withSelectNote = true
): string {
if (parents.length === 0) {
return "";
Expand Down Expand Up @@ -224,7 +228,22 @@ function generateParentSeedingCode(
emitParentSeeding(parent.key, parent.entity, 0);
}

return codeBlocks.join("\n");
if (codeBlocks.length === 0) {
return "";
}

if (!withSelectNote) {
return codeBlocks.join("\n");
}

// Callers place this BEFORE navigating to the child page: the child's parent <select> is
// filled by an async list query on mount, so seeding first means that fetch already includes
// the parents. An API seed AFTER navigation isn't reflected in the fetched list, so the
// <option> never appears and selectOption times out (30s) → false park.
const note =
" // Seed parent(s) up front so the child page's async parent-<select> lists them.";

return [note, ...codeBlocks].join("\n");
}

/**
Expand Down Expand Up @@ -367,8 +386,14 @@ function generateFieldFillSteps(

if (parent) {
const varName = `${parent.key}Id`;

return ` await page.getByTestId("${ids.field(field.name)}").selectOption(${varName});`;
const sel = `page.getByTestId("${ids.field(field.name)}")`;

// The parent <select> is populated by an ASYNC list query (the child page fetches its
// parents). Wait for the SEEDED option to attach before selecting — otherwise
// selectOption races the in-flight fetch and Playwright waits the full 30s for an
// <option value="{seededId}"> that isn't in the DOM yet → timeout → false park.
return ` await ${sel}.locator(\`option[value="\${${varName}}"]\`).waitFor({ state: "attached", timeout: 10000 });
await ${sel}.selectOption(${varName});`;
}
}

Expand Down Expand Up @@ -589,7 +614,18 @@ export function generateEntitySpec(
entity.id,
spec
);
const negativeBlocks = generateNegativeBlocks(entity, parentSeedingCode);
// Negatives POST directly to the API (no navigation, no parent <select>), so seed without the
// form-oriented "<select>" rationale note — it would be misleading in an API-only block.
const negativeParentSeedingCode = generateParentSeedingCode(
entity.parents,
entity.id,
spec,
false
);
const negativeBlocks = generateNegativeBlocks(
entity,
negativeParentSeedingCode
);

// 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 Expand Up @@ -672,6 +708,7 @@ test.describe(${JSON.stringify(name)}, () => {

test(${JSON.stringify(stepTitle("create", entity.key, name))}, async ({ page, authedPage }) => {
await authedPage.dashboard.goto();
${parentSeedingCode}
await navigateTo${entity.id}(page);

// Capture WHY a create can silently fail: the api client throws on non-2xx and React Query
Expand Down Expand Up @@ -707,9 +744,7 @@ test.describe(${JSON.stringify(name)}, () => {
// Wait for form to appear (form may be inline, no URL change)
await page.getByTestId("${ids.form}").waitFor({ state: "visible", timeout: 5000 });

${parentSeedingCode}

// Fill all fields
// Fill all fields (parents were seeded before navigation, above)
${fieldFillSteps}
${fillFirstFieldCode} // Fill identity field with unique value
await page.getByTestId("${ids.field(identityFieldName)}").fill(unique);
Expand All @@ -736,6 +771,7 @@ ${rowCellAssertions}

test(${JSON.stringify(stepTitle("persist", entity.key, name))}, async ({ page, authedPage }) => {
await authedPage.dashboard.goto();
${parentSeedingCode}
await navigateTo${entity.id}(page);

const unique = ${uniqueValueConstruction};
Expand All @@ -744,8 +780,6 @@ ${rowCellAssertions}
await page.getByTestId("${ids.create}").click();
await page.getByTestId("${ids.form}").waitFor({ state: "visible", timeout: 5000 });

${parentSeedingCode}

${fieldFillSteps}
${fillFirstFieldCode} await page.getByTestId("${ids.field(identityFieldName)}").fill(unique);

Expand Down Expand Up @@ -794,6 +828,7 @@ ${entity.parents

test(${JSON.stringify(stepTitle("update", entity.key, name))}, async ({ page, authedPage }) => {
await authedPage.dashboard.goto();
${parentSeedingCode}
await navigateTo${entity.id}(page);

// Create a record stamped with a unique value (identity field, not first field)
Expand All @@ -811,8 +846,6 @@ ${entity.parents
await page.getByTestId("${ids.create}").click();
await page.getByTestId("${ids.form}").waitFor({ state: "visible", timeout: 5000 });

${parentSeedingCode}

${fieldFillSteps}
${fillFirstFieldCode} await page.getByTestId("${ids.field(identityFieldName)}").fill(unique);

Expand Down Expand Up @@ -849,6 +882,7 @@ ${fillFirstFieldCode} await page.getByTestId("${ids.field(identityFieldName)}

test(${JSON.stringify(stepTitle("delete", entity.key, name))}, async ({ page, authedPage }) => {
await authedPage.dashboard.goto();
${parentSeedingCode}
await navigateTo${entity.id}(page);

const unique = ${uniqueValueConstruction};
Expand All @@ -857,8 +891,6 @@ ${fillFirstFieldCode} await page.getByTestId("${ids.field(identityFieldName)}
await page.getByTestId("${ids.create}").click();
await page.getByTestId("${ids.form}").waitFor({ state: "visible", timeout: 5000 });

${parentSeedingCode}

${fieldFillSteps}
${fillFirstFieldCode} await page.getByTestId("${ids.field(identityFieldName)}").fill(unique);

Expand Down
115 changes: 115 additions & 0 deletions packages/core/tests/boringstack-e2e-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2221,3 +2221,118 @@ describe("PART C: negative check hardening tests", () => {
expect(spec).not.toContain('payload["email"] = false');
});
});

describe("child-entity acceptance: parent-FK <select> ordering (Contact belongsTo Company)", () => {
// A child entity whose form has a company parent-FK rendered as a native <select>.
// Field order mirrors a real derived spec: domain fields first, FK appended LAST
// (planToAcceptanceSpec's addParentFkFields pushes FK fields to the end), so the identity
// field resolves to `name` and the generated create test fills `name` (not the FK <select>).
const contact: IEntityAcceptance = {
id: "Contact",
key: "contact",
nav: "Contacts",
fields: [
{
name: "name",
type: "string",
optional: false,
valid: "Jane",
invalid: [],
},
{
name: "companyId",
type: "string",
optional: false,
valid: "",
invalid: [],
},
],
shows: ["name", "company"],
screens: ["list", "form"],
parents: [{ entity: "Company", key: "company", fkField: "companyId" }],
negatives: [{ field: "name", value: "", why: "name is required" }],
acceptanceCheck: "create a contact under a company",
};

// Extract one test block by a substring of its title, up to the next top-level ` test(`.
const extractBlock = (spec: string, titleMarker: string): string => {
const start = spec.indexOf(titleMarker);

if (start === -1) {
return "";
}

const rest = spec.slice(start);
const next = rest.indexOf("\n test(", 1);

return next === -1 ? rest : rest.slice(0, next);
};

// Every test that navigates to the child page and fills its parent-FK <select>.
const formDrivenMarkers = [
"create Contact: form fill",
"Contact persists after page reload",
"update Contact: edit form",
"delete Contact: row delete",
];

test("EVERY form-driven test seeds the parent via API BEFORE navigating to the child page (per-test, not just the first block)", () => {
// The bug this guards: seeding AFTER navigation left the child page's already-fetched
// company list without the seed, so the <option value={seededId}> never appeared and
// selectOption timed out (30s) → false park. Asserting only the first spec-wide occurrence
// would miss a persist/update/delete block that still seeds late — so check each block.
const spec = generateEntitySpec(contact);

for (const marker of formDrivenMarkers) {
const block = extractBlock(spec, marker);

expect(block, `missing test block: ${marker}`).not.toBe("");

const seedIdx = block.indexOf("/api/v1/company");
const navIdx = block.indexOf("navigateToContact(page)");

expect(seedIdx, `${marker}: no parent seed`).toBeGreaterThan(-1);
expect(navIdx, `${marker}: no navigateToContact`).toBeGreaterThan(-1);
expect(
seedIdx,
`${marker}: parent seed must precede navigateToContact`
).toBeLessThan(navIdx);
}
});

test("the parent-<select> rationale note appears in form-driven tests but NOT in the API-only negative block (which still seeds the parent)", () => {
const spec = generateEntitySpec(contact);
const note = "async parent-<select>";

// Present in the form-driven seeding (child page navigates + selects the <select>).
expect(spec).toContain(note);

// The negative block POSTs directly (no navigation, no <select>): it must still seed the
// parent for the FK payload, but omit the misleading <select> note.
const negIdx = spec.indexOf("negative: Contact rejects");

expect(negIdx).toBeGreaterThan(-1);

const negBlock = spec.slice(negIdx);

expect(negBlock).toContain("/api/v1/company"); // still seeds the parent for the FK payload
expect(negBlock).not.toContain(note); // but no misleading <select> rationale
});

test("waits for the seeded <option> to attach before selectOption (no async-fetch race)", () => {
const spec = generateEntitySpec(contact);
const waitIdx = spec.indexOf('option[value="${companyId}"]');
const selectIdx = spec.indexOf(".selectOption(companyId)");

expect(waitIdx).toBeGreaterThan(-1);
expect(selectIdx).toBeGreaterThan(-1);
expect(waitIdx).toBeLessThan(selectIdx);
});

test("a single-entity slice (no parents) emits no FK <select> wait/selection — the fix is scoped to relational children", () => {
const spec = generateEntitySpec(company); // company has parents: []

expect(spec).not.toContain('option[value="${'); // no FK option-attached wait
expect(spec).not.toContain(".selectOption("); // no parent-FK selection at all
});
});