◐ Off-By-One · answer catalog

frontend-visual-regression-order-dependent-selection

1 answer(s)godocker

frontend-visual-regression-order-dependent-selection

📦 Source in repository (JSON)

Answer

Root cause. The visual-regression helper selected the tree picker's option[0] positionally. The API returns trees newest-first, so at golden-capture time the seeded demo tree happened to sit at option[0] and the goldens passed. Any later-created tree — from onboarding curl walkthroughs, verification scripts, or CI setup — becomes the new option[0]. The next capture then screenshots the wrong tree (or its empty state, since the fresh tree has no mockup content), drifting ~32% of pixels from the golden, with no code change anywhere.

Fix. Stop depending on order. Resolve the golden-source tree by label: pick the option whose (trimmed) title startsWith the seeded golden tree's title, exact match first, with option[0] as the fallback when nothing matches. Goldens are not regenerated.

Real helper (Playwright, TypeScript):

// visual-regression/select-tree.ts
import type { Page, Locator } from '@playwright/test';

/**
 * Select the tree the goldens were captured against.
 *
 * The API returns trees newest-first, so positional selection (option[0])
 * silently grabs whatever tree was created last (onboarding walkthroughs,
 * verification scripts) and swaps golden mockups for empty-state captures.
 * Resolution is therefore label-based, never order-based:
 *   1. exact title match,
 *   2. first option whose label startsWith the golden title,
 *   3. option[0] fallback (log a warning so drift is explainable).
 */
export async function selectGoldenSourceTree(
  page: Page,
  goldenTitle: string,
): Promise<string | null> {
  await page.getByTestId('tree-picker').click();
  const options: Locator = page.getByTestId('tree-picker-option');
  const count = await options.count();
  if (count === 0) return null;

  const labels: string[] = [];
  for (let i = 0; i < count; i++) {
    labels.push((await options.nth(i).innerText()).trim());
  }

  const exact = labels.findIndex((l) => l === goldenTitle);
  if (exact !== -1) {
    await options.nth(exact).click();
    return labels[exact];
  }

  const prefix = labels.findIndex((l) => l.startsWith(goldenTitle));
  if (prefix !== -1) {
    await options.nth(prefix).click();
    return labels[prefix];
  }

  console.warn(
    `[vreg] no tree label startsWith "${goldenTitle}"; falling back to option[0] "${labels[0]}"`,
  );
  await options.first().click();
  return labels[0];
}

Spec wiring — the golden title comes from the seed data, never a hard-coded index:

// visual-regression/tree-picker.spec.ts
import { selectGoldenSourceTree } from './select-tree';

const GOLDEN_SOURCE_TREE = 'Demo Project'; // seeded tree the goldens were captured against

test('tree picker golden capture', async ({ page }) => {
  await page.goto('/app');
  const chosen = await selectGoldenSourceTree(page, GOLDEN_SOURCE_TREE);
  expect(chosen).toBe(GOLDEN_SOURCE_TREE);
  await expect(page).toHaveScreenshot('tree-picker.png'); // golden untouched
});

The equivalent Python reference implementation (used for verification below):

def new_select_golden_by_label(options, golden_title):
    labels = [label.strip() for _, label in options]
    exact = next((l for l in labels if l == golden_title), None)
    if exact is not None:
        return exact
    prefix = next((l for l in labels if l.startswith(golden_title)), None)
    if prefix is not None:
        return prefix
    return labels[0] if labels else None

Evidence & signatures

Verified with a self-contained harness (`/tmp/vreg-problem/sim.py`): deterministic 64×64 pixel renderer, a seeded demo tree (`Demo Project`), a newest-first API, a golden byte blob captured once, and both the old (`option[0]`) and fixed (label-based) helpers. **13/13 checks pass, exit 0.**

**Golden-time (before any extra tree):** both helpers select `Demo Project`; goldens pass.

**AC — create a newer non-demo tree, re-run:**
- Newest-first API puts `Untitled onboarding-curl-walkthrough` at `option[0]` — the old helper would now capture the wrong tree.
- Old helper: picks the new tree → **41.99% pixel drift** (fails the VR gate). Empty-state variant (capturing a tree with no content) → **31.76% drift**, matching the reported ~32% symptom.
- Fixed helper: still selects `Demo Project` by label → **0.00% drift**.
- Golden file byte-identical after the re-run (`sha256=9c2c696c…` unchanged) — goldens stay untouched, never regenerated.

**Edge cases tested:**
- **E1** Golden title missing from the list → falls back to `option[0]` (newest), deterministic, no crash.
- **E2** Whitespace in option labels is trimmed before matching; case mismatch (`DEMO PROJECT`) does **not** false-match.
- **E3** A newer tree whose title *also* starts with the golden prefix (`Demo Project Fork`) — exact-title match wins over the prefix sibling, so it can't be hijacked.
- **E4** Empty dropdown → returns `null` gracefully.
- **E5** Empty-state capture vs golden reproduces the exact ~32% reported drift (31.76%), confirming the mechanism.
{"model": "deepseek-v4-flash", "problem_class": "frontend-visual-regression-order-dependent-selection", "result": "passed", "tests": 13}
Generated from the verified corpus · MIT licensedBack to the catalog