Skip to content

Documentation

E2E Troubleshooting Guide

Common failures, debugging patterns, and isolation strategies for the SveltyCMS Playwright suite.

7/26/2026
7 min read Edit on GitHub

This guide covers common issues encountered while running Playwright E2E tests in the SveltyCMS environment.

1. Setup Wizard Re-runs (One-Time Initialization)

Important

The Problem: By default, SveltyCMS only allows the Setup Wizard to run if no configuration exists. Once a test completes the wizard, subsequent tests might be redirected to /login.

The Solution: SveltyCMS uses a “Hard Reset” strategy in TEST_MODE.

  • Database Isolation: Playwright uses a database-per-worker pattern. Each worker (e.g., worker0) gets its own brand-new SQLite database file (cms_worker0.db).
  • Testing API Reset: Every test should call POST /api/testing { "action": "reset" } in its beforeEach hook.
  • Hard Reset: In TEST_MODE, the reset action automatically deletes the config/private.test.ts file, forcing the system back into “waiting for setup” mode.

2. ECONNREFUSED Errors (Server Re-initialization)

Warning

The Problem: When the Setup Wizard completes (or the Testing API setup action is called), the server performs a reinitializeSystem(). This process briefly drops active connections, which can cause Playwright to throw ECONNREFUSED.

Debugging Steps:

  1. Check the server logs for [System] Re-initializing....
  2. Ensure your test uses the retry wrapper for final synchronization calls.
  3. If the error persists, increase the waitForTimeout between the Setup POST and the next verification step.

3. UI Selectors & Brittle CSS

Tip

Best Practice: Avoid using brittle CSS classes for selectors (e.g., .btn-filled-primary, .variant-ghost). Prefer data-testid or ARIA labels.

The Solution:

  • Priority 1: Use data-testid (e.g., page.getByTestId('db-host')).
  • Priority 2: Use ARIA labels or role-based locators (e.g., page.getByLabel('Next')).
  • Priority 3: Use text-based locators if unique (e.g., page.getByText('Success!')).

4. Worker Isolation & Lock Failures

SveltyCMS standardizes on 127.0.0.1:4173 for testing.

  • SQLite Locking: If you see SqliteError: database is locked, it means the system is sharing one database across multiple workers.
  • The Solution: Ensure your test runner correctly propagates the x-test-worker-index header. The SveltyCMS SQLite adapter uses this header to route traffic to isolated files (e.g., cms_worker_1.db).
  • Isolation Check: Look for [db] Switched to worker isolation file: cms_worker_X.db in the server logs.

5. Identifying the State

You can verify the current system state via the logs:

  • [setupCheck] private.test.ts NOT FOUND -> System is in Setup Mode.
  • [setupCheck] private.test.ts FOUND -> System is initialized.
  • SQLite adapter error [GET_ACTIVE_THEME_FAILED] -> (Legacy) System is cold-starting. Fixed in v0.0.7 to return null gracefully.

6. Custom Svelte Components & Locator Pitfalls

SveltyCMS uses custom component wrappers (<Checkbox>, <Button>, <Input>, <DialogManager>) that render native HTML elements but alter their positioning, visibility, and event handling. Tests that target the raw DOM nodes can fail in subtle ways.

6a. check({ force: true }) on sr-only Checkbox Inputs

Warning

The Problem: The <Checkbox> component renders a hidden native <input type="checkbox" class="sr-only"> (1×1px, position: absolute, clip: rect(0,0,0,0)). checkbox.check() calls scrollIntoViewIfNeeded(), which fails because the element cannot be scrolled into the viewport — it’s visually clipped to nothing.

Symptoms: locator.check: Element is outside of the viewport even with { force: true }.

The Solution — three options, in order of preference:

  1. Click the visible <label> instead. The Checkbox component renders a <label for={id}> that is a 24×24px clickable target. Clicking it toggles the native checkbox via native HTML behavior:

    // Instead of:
    await checkbox.check({ force: true });
    // Use:
    await section.locator("label").first().click();
  2. Use click({ force: true }) instead of check(). The force option on click() bypasses the viewport scroll-in that check() requires:

    await checkbox.click({ force: true, timeout: ACTION_TIMEOUT });
  3. Toggle explicitly when you need to change state regardless of current value:

    if (await checkbox.isChecked()) {
      await checkbox.uncheck({ force: true });
    } else {
      await checkbox.check({ force: true });
    }

6b. .locator() Chaining Creates Descendant Queries

Warning

The Problem: In Playwright, cellCheckboxes.locator(":not([disabled])") creates a descendant query — it searches for elements matching :not([disabled]) that are children of each checkbox. Since <input> is a void element with no children, this always returns 0.

Symptoms: expect(count).toBeGreaterThan(0) fails when checkboxes clearly exist in the DOM.

The Solution: Combine CSS pseudo-classes into a single locator string:

// ❌ Descendant query — always 0
const toggleable = cellCheckboxes.locator(":not([disabled])");

// ✅ Combined CSS selector
const toggleable = page.locator('input[type="checkbox"]:not([disabled])');

6c. Portal-Rendered Dialogs Have a Content Render Gap

Warning

The Problem: DialogManager uses a <Portal> component that moves the <dialog> element to document.body on mount. The dialog shell is visible immediately, but the dialog content (e.g., <ConfirmDialog> with its buttons) renders in a subsequent Svelte microtask. Chaining locators from the dialog element hits a race where the shell exists but content hasn’t arrived.

Symptoms: getByRole('dialog').first().getByRole('button', { name: /confirm/i }) — the dialog IS found and visible, but no button exists inside it. Timeout exhausted even at 15s.

The Solution: Search at page-level instead of chaining from the dialog:

// ❌ Chained — races with Portal async render
const confirmBtn = dialog.getByRole("button", { name: /confirm/i });

// ✅ Page-level — waits for any button on the page matching the name
const confirmBtn = page.getByRole("button", { name: /confirm/i }).first();
await expect(confirmBtn).toBeVisible({ timeout: ACTION_TIMEOUT });

6d. check() Is a No-Op When Already Checked

Warning

The Problem: Playwright’s locator.check() only dispatches events if the checkbox is unchecked. If the default state is checked: true, check() returns immediately without firing any change event. Svelte’s onchange handler never runs, and any waitForResponse on the expected API call times out.

Symptoms: page.waitForResponse times out — the API call was never made. The checkbox visually toggles fine when clicked manually.

The Solution: Always toggle explicitly when you need the change event to fire:

const isChecked = await checkbox.isChecked();
const apiCall = page.waitForResponse(/* ... */);
if (isChecked) {
  await checkbox.uncheck({ force: true });
} else {
  await checkbox.check({ force: true });
}
const res = await apiCall;

7. API Response Wrapping & Integration Tests

Warning

The Problem: SveltyCMS API handlers use successResponse(event, data) which wraps every response as { success: true, data: <your-data> }. Integration tests that call endpoints directly via fetch() receive this wrapped shape, but often access the payload as if it were unwrapped.

Symptoms: expect(result.valid).toBe(true) fails — result.valid is undefined because the actual path is result.data.valid.

The Solution: Always unwrap the data envelope in integration tests:

const response = await safeFetch(`${API_BASE_URL}/api/user/verify-password`, {/* ... */});
expect(response.status).toBe(200);
const result = await response.json();
// ✅ Unwrap the successResponse envelope
expect(result.data.valid).toBe(true);
// ❌ Don't access payload at root level
// expect(result.valid).toBe(true);

The same applies to collection endpoints — GET /api/collections/{slug} returns:

{ "success": true, "data": [/* entries */], "meta": {} }

Assertions should check body.data (the array), not body._id (doesn’t exist).

8. Private Config Fields & Persistence Tests

Warning

The Problem: Settings fields with category: "private" (e.g., CACHE_TTL_SCHEMA, DB_PORT, JWT_SECRET_KEY) are sourced from config/private.ts on fresh page loads. Saving them through the System Settings UI updates the database, but a page reload reads the config file value, overwriting the DB change.

Symptoms: After a successful settings save (API returns 200, “Saved” toast visible, save button disabled), a page reload shows the input with the original config-file value (or empty). The DB save worked, but the UI never reflects it because the config file takes precedence on load.

The Solution: For persistence tests on category: "private" fields, verify via direct API call instead of UI input value after reload:

// ❌ Reload + check input — config file overrides DB
await goSettings(page, "cache");
await expect(input).toHaveValue(target); // shows config-file value, not saved value

// ✅ Verify persistence via API
const verify = await page.request.get(`/api/settings/cache?bypassCache=true`);
const body = await verify.json();
expect(body.values?.CACHE_TTL_SCHEMA).toBe(Number(target));

For field-level edit/save/discard tests (without reload), private fields work fine because the in-memory state holds the DB value until a full page load resets it.


Related

testinge2eplaywrighttroubleshootinglocators
Was this page helpful?