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
10 changes: 6 additions & 4 deletions docs/e2e-test-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ To skip the one-time prebuild for faster local iteration:

## Per-Test Isolation

Each E2E spec should reset state in `beforeEach` with existing helpers:
The `tauriPage` fixture owns per-test reset before the app launches:

1. Reset isolated config DB path for the run.
2. Return to home screen with stable UI helpers.
3. Reset workspace test directories.
2. Reset workspace test directories.
3. Start a fresh Tauri app process for the test.

The `reloadToHome()` helper in `e2e/helpers/ui.ts` is the source of truth for the in-app reset path. It clears `sg_workspace_hint` from session storage _before_ navigating to home (so the home page never auto-navigates back to the previous workspace), then uses UI-driven navigation via `ensureHome()`. A full `window.location.reload()` is intentionally avoided — SvelteKit route navigation already tears down and remounts all page components, and hard reloads take 20–45 s on slow Windows CI runners.
This ordering is required on Windows. Resetting the config DB or workspace directories after the app is already running can race startup reads, file watchers, and terminal child processes, producing `database is locked`, `EBUSY`, and `beforeEach` timeout flakes.

Specs should assume a fresh app on first interaction and should not perform their own reset in `beforeEach` unless a test explicitly needs an in-app navigation step within the same process.

## Browser Dependency Setup

Expand Down
15 changes: 10 additions & 5 deletions docs/tauri-playwright-adapter-cheatsheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,9 @@ If each process computes independently, worker and webServer can drift and fail
- Keep webServer startup timeout modest (around 90s).
- Keep plugin connect timeout modest (around 30s).
- Fail fast on startup error toasts, and attach them to test artifacts.
- Reset state per spec in `beforeEach`, not via a global Playwright hook.
- For per-test isolation, delete the E2E workspace dir and config DB, then return to the home screen with stable in-app navigation.
- Prefer a persistent app process plus per-test state reset over restarting Tauri between tests.
- Reset disk state in the fixture before launching Tauri for each test.
- For per-test isolation, delete the E2E workspace dir and config DB before app startup; do not delete them from a spec-level `beforeEach` after the app has already opened them.
- Keep the app lifecycle model consistent. If the fixture launches a fresh Tauri process per test, specs should not layer an in-app reset path on top of that.

## Debug Checklist

Expand Down Expand Up @@ -190,10 +190,15 @@ These are real failures we have already hit in this repo and what they usually m

7. `Failed to load config ... database is locked` during E2E state reset

- Meaning: test cleanup deleted or mutated the config DB while an operation still had it open, or multiple processes shared the same config DB.
- Fix: scope `SPROUTGIT_CONFIG_DB_PATH` per run, keep workers at `1`, and reset the config DB before the next test starts. SproutGit opens config DB connections on demand, so deleting the isolated DB between tests is safe when no command is actively using it.
- Meaning: test cleanup deleted or mutated the config DB while the app had already started and still had it open, or multiple processes shared the same config DB.
- Fix: scope `SPROUTGIT_CONFIG_DB_PATH` per run, keep workers at `1`, and reset the config DB before launching the next test's app process.

8. Full suite flakes when using hard reload in every `beforeEach`

- Meaning: forcing `window.location.assign('/')` / `window.location.reload()` before each spec can be less stable than UI-driven navigation in `tauri` mode when tests share one long-lived app process.
- Fix: use a persistent app process, reset disk state between tests, and use an `ensureHome()` helper that clicks back to the project list and waits for stable home-screen test IDs. Keep hard reloads as a targeted debugging tool, not the suite default.

9. `beforeEach` times out before the first assertion on Windows

- Meaning: the suite is spending its timeout budget deleting files or waiting on teardown after the app already launched.
- Fix: move reset into the fixture so config/workspace cleanup happens before `TauriProcessManager.start()`.
43 changes: 42 additions & 1 deletion e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@srsholmes/tauri-playwright';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resetConfigDb, resetTestDirs } from './helpers/fixtures';

const MCP_SOCKET =
process.env.SPROUTGIT_PLAYWRIGHT_SOCKET_PATH ?? join(tmpdir(), 'sproutgit-playwright.sock');
Expand Down Expand Up @@ -42,16 +43,29 @@ function parseCommandSpec(spec: string): { command: string; args: string[] } {

type Fixtures = {
mode: 'tauri';
_resetE2EState: void;
tauriPage: TauriPage;
};

export const test = base.extend<Fixtures>({
mode: ['tauri', { option: true }],
tauriPage: async ({ mode }, use) => {
_resetE2EState: [
async ({}, use) => {
// Reset disk state before the app launches so startup code never races
// the config DB deletion or workspace directory cleanup.
resetConfigDb();
resetTestDirs();
await use();
},
{ auto: true },
],
tauriPage: async ({ mode, _resetE2EState }, use) => {
if (mode !== 'tauri') {
throw new Error(`Unsupported E2E mode: ${mode}`);
}

void _resetE2EState;

let processManager: TauriProcessManager | null = null;
let client: PluginClient | null = null;

Expand Down Expand Up @@ -92,7 +106,34 @@ export const test = base.extend<Fixtures>({
await use(tauriPage);
} finally {
client?.disconnect();

// On Windows, processManager.stop() calls TerminateProcess() on the Tauri
// parent process only. Child processes spawned by the Tauri app (e.g.
// PowerShell hook terminals) are NOT in the same Windows Job Object and
// are NOT killed — they become orphaned with their CWD still pointing at
// worktree directories, causing EBUSY on rmSync in the next test's reset.
//
// Fix: use `taskkill /F /T /PID` to kill the entire process tree before
// calling stop(), so all child processes release their directory handles.
if (IS_WINDOWS && processManager) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pid = (processManager as any).process?.pid as number | undefined;
if (pid) {
try {
const { execSync } = await import('node:child_process');
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });
} catch {
// Process may already have exited — ignore
}
}
}

processManager?.stop();
// Short grace period for the OS to fully release file handles after the
// process tree is terminated. taskkill /F /T is synchronous so 500ms is
// sufficient; the previous 2s was compensating for orphaned children that
// are now killed above.
await new Promise((resolve) => setTimeout(resolve, 500));
}
},
});
Expand Down
64 changes: 55 additions & 9 deletions e2e/helpers/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process';
import { appendFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Faker, en } from '@faker-js/faker';
Expand Down Expand Up @@ -86,23 +86,53 @@ export function runGit(cwd: string, args: string[], extraEnv: Record<string, str
}

export function cleanupTestDirs() {
// On Windows the notify watcher (or git itself) can hold directory handles
// briefly after the watcher is stopped. Retry a few times before giving up.
const maxAttempts = process.platform === 'win32' ? 20 : 1;
const retryDelayMs = 250;
if (!existsSync(TEST_DIR)) {
return;
}

// On Windows the notify watcher and terminal child processes can keep a
// worktree directory handle alive briefly after teardown begins. Keep trying
// with a bounded linear backoff so CI can absorb transient release latency.
const maxAttempts = process.platform === 'win32' ? 45 : 1;
const baseRetryDelayMs = 250;
const maxRetryDelayMs = 1_500;
let lastRetryableError: unknown = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
rmSync(TEST_DIR, { recursive: true, force: true });
return;
} catch (err: unknown) {
if (!existsSync(TEST_DIR)) {
return;
}
const isLastAttempt = attempt === maxAttempts;
const code = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : '';
const isRetryable = code === 'EBUSY' || code === 'EPERM' || code === 'ENOTEMPTY';
if (isLastAttempt || !isRetryable) throw err;
if (!isRetryable) throw err;
lastRetryableError = err;
if (isLastAttempt) break;
// Synchronous busy-wait: sleep between retries.
const retryDelayMs = Math.min(baseRetryDelayMs * attempt, maxRetryDelayMs);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs);
}
}

// If a handle remains stuck after retries on Windows, quarantine the current
// directory so the next test can still start with a fresh TEST_DIR. This is
// preferable to failing the whole suite on transient file-lock lag.
if (process.platform === 'win32' && existsSync(TEST_DIR)) {
const quarantinePath = `${TEST_DIR}.stale-${Date.now()}`;
try {
renameSync(TEST_DIR, quarantinePath);
return;
} catch {
// Fall through to the original failure for debugging if quarantine fails.
}
}

if (lastRetryableError) {
throw lastRetryableError;
}
}

export function setupTestDirs() {
Expand All @@ -117,9 +147,25 @@ export function resetTestDirs() {

export function resetConfigDb() {
if (CONFIG_DB_PATH) {
rmSync(CONFIG_DB_PATH, { force: true });
rmSync(`${CONFIG_DB_PATH}-wal`, { force: true });
rmSync(`${CONFIG_DB_PATH}-shm`, { force: true });
const targets = [CONFIG_DB_PATH, `${CONFIG_DB_PATH}-wal`, `${CONFIG_DB_PATH}-shm`];
const maxAttempts = process.platform === 'win32' ? 20 : 1;
const retryDelayMs = 100;

for (const target of targets) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
rmSync(target, { force: true });
break;
} catch (err: unknown) {
const isLastAttempt = attempt === maxAttempts;
const code =
err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : '';
const isRetryable = code === 'EBUSY' || code === 'EPERM';
if (isLastAttempt || !isRetryable) throw err;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs);
}
}
}
}
}

Expand Down
106 changes: 0 additions & 106 deletions e2e/helpers/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,6 @@ function isMissingMainWindowError(error: unknown): boolean {
return error instanceof Error && error.message.includes("window 'main' not found");
}

function isWaitForFunctionTimeout(error: unknown): boolean {
return error instanceof Error && error.message.includes('waitForFunction timeout');
}

async function safeIsVisible(tauriPage: AdapterPage, selector: string) {
try {
return await tauriPage.isVisible(selector);
Expand All @@ -51,71 +47,6 @@ async function safeIsVisible(tauriPage: AdapterPage, selector: string) {
}
}

async function waitForMainWindow(tauriPage: AdapterPage, timeout: number) {
const deadline = Date.now() + timeout;

while (Date.now() < deadline) {
try {
await tauriPage.waitForFunction('document.readyState === "complete"', 1_000);
return;
} catch (error) {
if (!isMissingMainWindowError(error) && !isWaitForFunctionTimeout(error)) {
throw error;
}
await delay(200);
}
}

throw new Error('Main window did not become available for E2E interaction');
}

async function waitForHomeReady(tauriPage: AdapterPage, timeout: number) {
const deadline = Date.now() + timeout;

while (Date.now() < deadline) {
let state: unknown;
try {
state = await tauriPage.evaluate(`(() => {
const importButton = document.querySelector('[data-testid="btn-import"]');
const main = document.querySelector('main');
const mainText = main?.textContent ?? '';

return {
pathname: window.location.pathname,
importVisible: importButton instanceof HTMLElement,
checkingGit: mainText.includes('Checking git'),
};
})()`);
} catch (error) {
if (!isMissingMainWindowError(error)) {
throw error;
}
await delay(200);
continue;
}

if (
state &&
typeof state === 'object' &&
'pathname' in state &&
'importVisible' in state &&
(state as { pathname: string }).pathname === '/' &&
(state as { importVisible: boolean }).importVisible
) {
return;
}
await delay(200);
}

throw new Error('Home screen did not finish bootstrapping after reload');
}

async function clearCachedWorkspaceHint(tauriPage: AdapterPage) {
await tauriPage.evaluate(`(() => {
sessionStorage.removeItem('sg_workspace_hint');
})()`);
}

async function waitForOptionalToastMessage(
tauriPage: AdapterPage,
type: 'success' | 'error' | 'warning' | 'info',
Expand All @@ -130,43 +61,6 @@ async function waitForOptionalToastMessage(
}
}

export async function reloadToHome(tauriPage: AdapterPage) {
// reloadToHome has a strict budget: the entire beforeEach (including this
// function) must complete within the 90 s per-test timeout. Every await
// below can burn up to STARTUP_UI_TIMEOUT (45 s), so keeping the chain
// short is critical.
//
// Sequence rationale:
// 1. waitForMainWindow — ensure the window is alive before evaluating
// 2. clearCachedWorkspaceHint — remove sg_workspace_hint BEFORE navigation;
// this prevents the home page onMount from
// auto-navigating back to the previous workspace
// 3. ensureHome — navigate to home via UI (clicks
// btn-back-projects when needed); on return,
// btn-import is visible — no extra waitForHomeReady
//
// A full window.location.reload() is NOT performed. SvelteKit route
// navigation already tears down and remounts all page components, giving the
// same isolation as a hard reload. The reload was the dominant cost on slow
// Windows CI runners (WebView cold-start ≈ 20–45 s), routinely pushing
// beforeEach past the 90 s test timeout. The Tauri Playwright adapter
// cheatsheet also recommends UI-driven navigation over hard reloads as the
// suite default.
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await waitForMainWindow(tauriPage, STARTUP_UI_TIMEOUT);
await clearCachedWorkspaceHint(tauriPage);
await ensureHome(tauriPage);
return;
} catch (error) {
if (attempt === 1) {
throw error;
}
await delay(250);
}
}
}

export async function ensureHome(tauriPage: AdapterPage) {
const deadline = Date.now() + STARTUP_UI_TIMEOUT;

Expand Down
9 changes: 1 addition & 8 deletions e2e/specs/canary-repos.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import { test, expect } from '../fixtures';
import { CANARY_REPOS, materializeCanaryRepo } from '../helpers/benchmark-repos';
import { resetConfigDb, resetTestDirs } from '../helpers/fixtures';
import { importRepoViaUi, reloadToHome } from '../helpers/ui';
import { importRepoViaUi } from '../helpers/ui';

test.describe('Canary repositories @canary', () => {
test.skip(
!process.env.RUN_CANARY,
'Set RUN_CANARY=1 to run non-blocking canary repository checks'
);

test.beforeEach(async ({ tauriPage }) => {
resetConfigDb();
await reloadToHome(tauriPage);
resetTestDirs();
});

for (const canary of CANARY_REPOS) {
test(`imports ${canary.name} and renders the workspace shell`, async ({ tauriPage }) => {
const repoPath = materializeCanaryRepo(canary);
Expand Down
16 changes: 1 addition & 15 deletions e2e/specs/commit-workflow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,15 @@
import { test, expect } from '../fixtures';
import { dirname, join } from 'node:path';
import {
createTestRepo,
querySqlite,
resetConfigDb,
resetTestDirs,
runGit,
writeRepoFile,
} from '../helpers/fixtures';
import { createTestRepo, querySqlite, runGit, writeRepoFile } from '../helpers/fixtures';
import {
createWorktreeViaUi,
DEFAULT_UI_TIMEOUT,
importRepoViaUi,
openChangesTab,
openHistoryTab,
reloadToHome,
} from '../helpers/ui';

test.describe('Commit workflow', () => {
test.beforeEach(async ({ tauriPage }) => {
resetConfigDb();
await reloadToHome(tauriPage);
resetTestDirs();
});

test('stages and commits changes from a managed worktree', async ({ tauriPage }) => {
const repoPath = createTestRepo('commit-test', { extraCommits: 1 });

Expand Down
Loading
Loading