Skip to content
Documentation GitHub
Test Failures

E2E Selector Drift After Design Token CSS Refactor

E2E Selector Drift After Design Token CSS Refactor

Problem

After a design-token refactor that replaced hardcoded Tailwind classes with semantic CSS custom properties, 52 out of 142 E2E tests failed. Every spec file was affected.

Symptoms:

  • waitForAppReady() hangs indefinitely (looking for text=Pages heading that no longer exists)
  • Selectors like .bg-blue-600, .bg-gray-800, .text-red-500 find 0 elements
  • textarea[placeholder="Start writing..."] finds nothing (editor changed from textarea to TipTap/ProseMirror)
  • Tests expecting a “Back” button fail (two-panel layout replaced single-panel navigation)

Investigation

Steps Tried

  1. Ran full E2E suite - 52 failures across all spec files. Not a single-test issue.
  2. Compared selectors to actual UI - Read each component’s source to map old selectors to new ones. Found systematic CSS class renaming:
    • bg-blue-600bg-accent-base
    • bg-gray-800bg-bg-elevated
    • text-red-500text-status-danger
    • text=Pages heading → removed entirely (sidebar uses data-tour attributes)
  3. Fixed centralized selectors first - Updated test-base.ts selectors object, which fixed most tests
  4. Found hardcoded selectors in spec files - Some tests had inline selectors that bypassed the central selectors object
  5. Found structural UI changes - Editor changed from <textarea> to ProseMirror, layout changed from single-panel with Back button to persistent two-panel

Root Cause

Design-token refactor changed CSS class names without updating E2E selectors. The refactor (commit 097d2f0) replaced raw Tailwind utility classes with semantic design tokens:

Old (Tailwind literal)New (Design token)
bg-blue-600bg-accent-base
bg-gray-800bg-bg-elevated
text-red-500text-status-danger
bg-blackbg-black/50 (with opacity)

Additionally, structural UI changes weren’t reflected in tests:

  • Editor: <textarea> → TipTap ProseMirror (.ProseMirror)
  • Navigation: Back button → persistent sidebar (two-panel layout)
  • Sidebar: <h2>Pages</h2> heading → no heading, uses data-tour attributes

Solution

Central Selector Updates (test-base.ts)

// Before
sidebar: 'text=Pages',
pageTree: 'text=Pages',
commandPaletteItem: '.bg-blue-600, [class*="bg-blue"]',
commandPaletteBackdrop: '.fixed.inset-0.bg-black',
importDialog: '.bg-gray-800.rounded-lg:has(h2:has-text("Import"))',
errorMessage: 'text=Error, .text-red-500',
// After
sidebar: '[data-tour="new-page"]',
pageTree: '[data-tour="page-tree"]',
commandPaletteItem: '[class*="bg-accent-base"]:not([data-tour])',
commandPaletteBackdrop: '.fixed.inset-0[class*="bg-black"]',
importDialog: '.bg-bg-elevated:has(h2:has-text("Import"))',
errorMessage: 'text=Error, [class*="text-status-danger"]',

Key Technique: :not() Exclusion for Ambiguous Selectors

The [class*="bg-accent-base"] selector matched both command palette results AND the sidebar’s ”+ New Page” button. Fixed with :not([data-tour]) since only sidebar elements have data-tour attributes.

Structural Test Updates (page.spec.ts)

// Before: textarea-based editor
const editor = page.locator('textarea[placeholder="Start writing..."]');
await editor.fill("content");
// After: TipTap/ProseMirror editor
const editor = page.locator('.ProseMirror');
await editor.click();
await page.keyboard.type("content");
// Before: single-panel with Back button
const backButton = page.locator('button:has-text("Back")');
await backButton.click();
// After: two-panel layout (sidebar always visible)
const pageTree = page.locator('[data-tour="page-tree"]');
await expect(pageTree).toBeVisible();

Implementation Notes

  • [class*="..."] attribute selectors match Tailwind utility classes even after purging
  • data-tour attributes serve double duty: tour targeting AND stable test selectors
  • waitForAppReady() changed from text=Pages to [data-tour="new-page"] - a structural element guaranteed to exist when the app is ready
  • ProseMirror requires click() + keyboard.type() instead of .fill() for text input

Prevention

Best Practices

  • Use data-tour or data-testid attributes as primary E2E selectors. These are stable across CSS refactors. The data-tour attributes in this codebase already serve this purpose.
  • Centralize all selectors in test-base.ts. Never hardcode selectors in spec files. The 52→9 failure reduction came from fixing test-base.ts; the remaining 9 were hardcoded in specs.
  • Run E2E tests after any CSS/design refactor. Even “cosmetic” changes can break test selectors.
  • Use [class*="..."] for Tailwind class matching instead of exact class selectors (.bg-blue-600). This is more resilient to class ordering changes, though still breaks on renames.

Warning Signs

  • E2E selectors that reference raw Tailwind classes (bg-blue-600, text-red-500)
  • waitForAppReady() relying on text content instead of structural elements
  • Tests referencing specific UI patterns (textarea, Back button) that could change with component library updates
  • Selectors in spec files that aren’t using the centralized selectors object

Selector Stability Hierarchy (Most → Least Stable)

  1. data-testid / data-tour attributes
  2. ARIA roles (getByRole)
  3. Semantic HTML (h1, input[type="text"])
  4. CSS attribute selectors ([class*="bg-accent"])
  5. Exact CSS classes (.bg-blue-600) - avoid
  6. Text content (text=Pages) - avoid for structural elements

Additional Patterns: Mock Command & Dialog State

Command Name Mismatch

Mock commands must match actual Tauri invoke() calls exactly. Read the UI source to verify:

// Wrong: mock uses different name than UI
case "analyze_import_source": ...
// Right: matches the actual invoke call
case "analyze_import": ...

Warning sign: [Tauri Mock] Unknown command in test logs.

Multi-Step Dialog Selectors

Dialogs that change state (e.g., “Import Content” → “Import Preview”) need separate selectors per step:

importDialog: '.bg-bg-elevated:has(h2:has-text("Import"))',
importPreviewDialog: '.bg-bg-elevated:has(h2:has-text("Import Preview"))',

Wait for the new state after triggering a transition:

await analyzeButton.click();
const previewDialog = page.locator(selectors.importPreviewDialog);
await expect(previewDialog).toBeVisible({ timeout: 5000 });

References

  • Commit: aa298b6 on branch matt/ink-50-create-page-does-not-persist-to-filesystem
  • Design token refactor: commit 097d2f0
  • Linear: INK-51
  • Files: test-base.ts, command-palette.spec.ts, first-launch.spec.ts, page.spec.ts

Was this page helpful?