/** * Board Background Persistence End-to-End Test * * Tests that board background settings are properly saved and loaded when switching projects. * This verifies that: * 1. Background settings are saved to .automaker/settings.json * 2. Settings are loaded when switching back to a project * 3. Background image, opacity, and other settings are correctly restored * 4. Settings persist across app restarts (new page loads) * * This test prevents regression of the board background loading bug where * settings were saved but never loaded when switching projects. */ import { test, expect } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; import { createTempDirPath, cleanupTempDir, authenticateForTests, handleLoginScreenIfPresent, } from '../utils'; // Create unique temp dirs for this test run const TEST_TEMP_DIR = createTempDirPath('board-bg-test'); test.describe('Board Background Persistence', () => { test.beforeAll(async () => { // Create test temp directory if (!fs.existsSync(TEST_TEMP_DIR)) { fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); } }); test.afterAll(async () => { // Cleanup temp directory cleanupTempDir(TEST_TEMP_DIR); }); test('should load board background settings when switching projects', async ({ page }) => { const projectAName = `project-a-${Date.now()}`; const projectBName = `project-b-${Date.now()}`; const projectAPath = path.resolve(TEST_TEMP_DIR, projectAName); const projectBPath = path.resolve(TEST_TEMP_DIR, projectBName); const projectAId = `project-a-${Date.now()}`; const projectBId = `project-b-${Date.now()}`; // Create both project directories fs.mkdirSync(projectAPath, { recursive: true }); fs.mkdirSync(projectBPath, { recursive: true }); // Create basic files for both projects for (const [name, projectPath] of [ [projectAName, projectAPath], [projectBName, projectBPath], ]) { fs.writeFileSync( path.join(projectPath, 'package.json'), JSON.stringify({ name, version: '1.0.0' }, null, 2) ); fs.writeFileSync(path.join(projectPath, 'README.md'), `# ${name}\n`); } // Create .automaker directory for project A with background settings const automakerDirA = path.join(projectAPath, '.automaker'); fs.mkdirSync(automakerDirA, { recursive: true }); fs.mkdirSync(path.join(automakerDirA, 'board'), { recursive: true }); fs.mkdirSync(path.join(automakerDirA, 'features'), { recursive: true }); fs.mkdirSync(path.join(automakerDirA, 'context'), { recursive: true }); // Copy actual background image from test fixtures const backgroundPath = path.join(automakerDirA, 'board', 'background.jpg'); const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg'); fs.copyFileSync(testImagePath, backgroundPath); // Create settings.json with board background configuration const settingsPath = path.join(automakerDirA, 'settings.json'); const backgroundSettings = { version: 1, boardBackground: { imagePath: backgroundPath, cardOpacity: 85, columnOpacity: 60, columnBorderEnabled: true, cardGlassmorphism: true, cardBorderEnabled: false, cardBorderOpacity: 50, hideScrollbar: true, imageVersion: Date.now(), }, }; fs.writeFileSync(settingsPath, JSON.stringify(backgroundSettings, null, 2)); // Create minimal .automaker directory for project B (no background) const automakerDirB = path.join(projectBPath, '.automaker'); fs.mkdirSync(automakerDirB, { recursive: true }); fs.mkdirSync(path.join(automakerDirB, 'features'), { recursive: true }); fs.mkdirSync(path.join(automakerDirB, 'context'), { recursive: true }); fs.writeFileSync( path.join(automakerDirB, 'settings.json'), JSON.stringify({ version: 1 }, null, 2) ); // Set up project A as the current project directly (skip welcome view). // The auto-open logic in __root.tsx always opens the most recent project when // navigating to /, so we cannot reliably show the welcome view with projects. const projectA = { id: projectAId, name: projectAName, path: projectAPath, lastOpened: new Date().toISOString(), }; const projectB = { id: projectBId, name: projectBName, path: projectBPath, lastOpened: new Date(Date.now() - 86400000).toISOString(), }; await page.addInitScript( ({ projects, versions, }: { projects: Array<{ id: string; name: string; path: string; lastOpened: string }>; versions: { APP_STORE: number; SETUP_STORE: number }; }) => { const appState = { state: { projects: projects, currentProject: projects[0], currentView: 'board', theme: 'dark', sidebarOpen: true, skipSandboxWarning: true, apiKeys: { anthropic: '', google: '' }, chatSessions: [], chatHistoryOpen: false, maxConcurrency: 3, boardBackgroundByProject: {}, }, version: versions.APP_STORE, }; localStorage.setItem('automaker-storage', JSON.stringify(appState)); const setupState = { state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false, }, version: versions.SETUP_STORE, }; localStorage.setItem('automaker-setup', JSON.stringify(setupState)); const settingsCache = { setupComplete: true, isFirstRun: false, projects: projects.map((p) => ({ id: p.id, name: p.name, path: p.path, lastOpened: p.lastOpened, })), currentProjectId: projects[0].id, theme: 'dark', sidebarOpen: true, sidebarStyle: 'unified', maxConcurrency: 3, }; localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache)); // Force unified sidebar (project-dropdown-trigger exists only in unified mode) const uiCache = { state: { cachedProjectId: projects[0].id, cachedSidebarOpen: true, cachedSidebarStyle: 'unified', cachedWorktreePanelCollapsed: false, cachedCollapsedNavSections: {}, cachedCurrentWorktreeByProject: {}, }, version: 2, }; localStorage.setItem('automaker-ui-cache', JSON.stringify(uiCache)); localStorage.setItem('automaker-disable-splash', 'true'); }, { projects: [projectA, projectB], versions: { APP_STORE: 2, SETUP_STORE: 1 } } ); // Fast-track initializeProject API calls for test project paths. // initializeProject makes ~8 sequential HTTP calls (exists, stat, mkdir, etc.) that // can take 10+ seconds under parallel load, blocking setCurrentProject entirely. await page.route('**/api/fs/**', async (route) => { const body = route.request().postDataJSON?.() ?? {}; const filePath = body?.filePath || body?.dirPath || ''; if (filePath.startsWith(projectAPath) || filePath.startsWith(projectBPath)) { const url = route.request().url(); if (url.includes('/api/fs/exists')) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, exists: true }), }); } else if (url.includes('/api/fs/stat')) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, stats: { isDirectory: true, isFile: false, size: 0, mtime: new Date().toISOString() }, }), }); } else if (url.includes('/api/fs/mkdir')) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true }), }); } else if (url.includes('/api/fs/write')) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true }), }); } else { await route.continue(); } } else { await route.continue(); } }); // Also fast-track git init for test projects await page.route('**/api/worktree/init-git', async (route) => { const body = route.request().postDataJSON?.() ?? {}; if ( body?.projectPath?.startsWith(projectAPath) || body?.projectPath?.startsWith(projectBPath) ) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, result: { initialized: false } }), }); } else { await route.continue(); } }); // Intercept settings API: inject test projects and track current project so that // when the app switches to project B (PUT), subsequent GETs return B instead of // overwriting back to A (which would prevent the dropdown from ever showing B). let effectiveCurrentProjectId = projectAId; let cachedSettingsJson: Record | null = null; await page.route('**/api/settings/global', async (route) => { const method = route.request().method(); if (method === 'PUT') { try { const body = route.request().postDataJSON(); if (body?.currentProjectId === projectAId || body?.currentProjectId === projectBId) { effectiveCurrentProjectId = body.currentProjectId; } } catch { // ignore parse errors } await route.continue(); return; } if (method !== 'GET') { await route.continue(); return; } if (!cachedSettingsJson) { try { const response = await route.fetch(); cachedSettingsJson = (await response.json()) as Record; } catch { // route.fetch() can fail during navigation; fall through to continue await route.continue().catch(() => {}); return; } } const json = JSON.parse(JSON.stringify(cachedSettingsJson)) as Record; if (!json.settings || typeof json.settings !== 'object') { json.settings = {}; } const settings = json.settings as Record; settings.currentProjectId = effectiveCurrentProjectId; settings.projects = [projectA, projectB]; settings.sidebarOpen = true; settings.sidebarStyle = 'unified'; await route .fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(json), }) .catch(() => {}); }); // Track API calls to /api/settings/project to verify settings are being loaded const settingsApiCalls: Array<{ url: string; method: string; body: string }> = []; page.on('request', (request) => { if (request.url().includes('/api/settings/project') && request.method() === 'POST') { settingsApiCalls.push({ url: request.url(), method: request.method(), body: request.postData() || '', }); } }); await authenticateForTests(page); // Navigate to the board directly with project A await page.goto('/board'); await page.waitForLoadState('load'); await handleLoginScreenIfPresent(page); // Wait for board view await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); // Wait for settings to be loaded (useProjectSettingsLoader hook) // Poll for the board view to be fully rendered and stable const boardView = page.locator('[data-testid="board-view"]'); await expect(boardView).toBeVisible({ timeout: 15000 }); // Wait for settings API calls to complete (at least one settings call should have been made) await expect(async () => { expect(settingsApiCalls.length).toBeGreaterThan(0); }).toPass({ timeout: 10000 }); // Ensure sidebar is expanded before interacting with project selector const expandSidebarButton = page.locator('button:has-text("Expand sidebar")'); if (await expandSidebarButton.isVisible()) { await expandSidebarButton.click(); await page .locator('button:has-text("Collapse sidebar")') .waitFor({ state: 'visible', timeout: 5000 }) .catch(() => {}); } // Switch to project B (no background) // Use retry pattern: background re-renders (worktree loading, settings sync) can // swallow clicks or close the dropdown immediately after it opens. const projectSelector = page.locator('[data-testid="project-dropdown-trigger"]'); await expect(async () => { await projectSelector.click(); await expect(page.locator('[data-testid="project-dropdown-content"]')).toBeVisible({ timeout: 2000, }); }).toPass({ timeout: 10000 }); const projectPickerB = page.locator(`[data-testid="project-item-${projectBId}"]`); await expect(projectPickerB).toBeVisible({ timeout: 5000 }); // Update effectiveCurrentProjectId eagerly BEFORE clicking so any in-flight GET // responses return project B instead of overwriting the store back to A. effectiveCurrentProjectId = projectBId; await projectPickerB.click(); // Wait for the project switch to take effect (dropdown trigger shows project B name). // With initializeProject API calls fast-tracked, setCurrentProject runs quickly // and the startTransition commits within a few seconds. await expect( page.locator('[data-testid="project-dropdown-trigger"]').getByText(projectBName) ).toBeVisible({ timeout: 15000 }); // Ensure sidebar stays expanded after navigation (it may collapse when switching projects) const expandBtn = page.locator('button:has-text("Expand sidebar")'); if (await expandBtn.isVisible()) { await expandBtn.click(); await page .locator('button:has-text("Collapse sidebar")') .waitFor({ state: 'visible', timeout: 5000 }) .catch(() => {}); } // Switch back to project A. Settings polls can cause re-renders that detach dropdown // items mid-click, so we retry the entire open-and-click sequence with short timeouts. // Update effectiveCurrentProjectId eagerly to prevent polls from reverting the switch. effectiveCurrentProjectId = projectAId; const trigger = page.locator('[data-testid="project-dropdown-trigger"]'); await expect(async () => { await trigger.click(); await expect(page.locator('[data-testid="project-dropdown-content"]')).toBeVisible({ timeout: 2000, }); await page .locator(`[data-testid="project-item-${projectAId}"]`) .click({ force: true, timeout: 1000 }); }).toPass({ timeout: 15000 }); // Verify we're back on project A await expect( page.locator('[data-testid="project-dropdown-trigger"]').getByText(projectAName) ).toBeVisible({ timeout: 15000 }); // Wait for settings to be re-loaded for project A const prevCallCount = settingsApiCalls.length; await expect(async () => { expect(settingsApiCalls.length).toBeGreaterThan(prevCallCount); }) .toPass({ timeout: 10000 }) .catch(() => { // Settings may be cached, which is fine }); // Verify that the settings API was called for project A at least once (initial load). // Note: When switching back, the app may use cached settings and skip re-fetching. const projectASettingsCalls = settingsApiCalls.filter((call) => call.body.includes(projectAPath) ); // Debug: log all API calls if test fails if (projectASettingsCalls.length < 1) { console.log('Total settings API calls:', settingsApiCalls.length); console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2)); console.log('Looking for path:', projectAPath); } expect(projectASettingsCalls.length).toBeGreaterThanOrEqual(1); // Verify settings file still exists with correct data const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); expect(loadedSettings.boardBackground).toBeDefined(); expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath); expect(loadedSettings.boardBackground.cardOpacity).toBe(85); expect(loadedSettings.boardBackground.columnOpacity).toBe(60); expect(loadedSettings.boardBackground.hideScrollbar).toBe(true); // Clean up route handlers to avoid "route in flight" errors during teardown await page.unrouteAll({ behavior: 'ignoreErrors' }); // The test passing means: // 1. The useProjectSettingsLoader hook is working // 2. Settings are loaded when switching projects // 3. The API call to /api/settings/project is made correctly }); test('should load background settings on app restart', async ({ page }) => { const projectName = `restart-test-${Date.now()}`; const projectPath = path.join(TEST_TEMP_DIR, projectName); const projectId = `project-${Date.now()}`; // Create project directory fs.mkdirSync(projectPath, { recursive: true }); fs.writeFileSync( path.join(projectPath, 'package.json'), JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) ); // Create .automaker with background settings const automakerDir = path.join(projectPath, '.automaker'); fs.mkdirSync(automakerDir, { recursive: true }); fs.mkdirSync(path.join(automakerDir, 'board'), { recursive: true }); fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); // Copy actual background image from test fixtures const backgroundPath = path.join(automakerDir, 'board', 'background.jpg'); const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg'); fs.copyFileSync(testImagePath, backgroundPath); const settingsPath = path.join(automakerDir, 'settings.json'); fs.writeFileSync( settingsPath, JSON.stringify( { version: 1, boardBackground: { imagePath: backgroundPath, cardOpacity: 90, columnOpacity: 70, imageVersion: Date.now(), }, }, null, 2 ) ); // Set up with project as current using direct localStorage await page.addInitScript( ({ project }: { project: string[] }) => { const projectObj = { id: project[0], name: project[1], path: project[2], lastOpened: new Date().toISOString(), }; const appState = { state: { projects: [projectObj], currentProject: projectObj, currentView: 'board', theme: 'dark', sidebarOpen: true, skipSandboxWarning: true, apiKeys: { anthropic: '', google: '' }, chatSessions: [], chatHistoryOpen: false, maxConcurrency: 3, boardBackgroundByProject: {}, }, version: 2, }; localStorage.setItem('automaker-storage', JSON.stringify(appState)); // Setup complete - use correct key name const setupState = { state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false, }, version: 1, }; localStorage.setItem('automaker-setup', JSON.stringify(setupState)); const settingsCache = { setupComplete: true, isFirstRun: false, projects: [ { id: projectObj.id, name: projectObj.name, path: projectObj.path, lastOpened: projectObj.lastOpened, }, ], currentProjectId: projectObj.id, theme: 'dark', sidebarOpen: true, maxConcurrency: 3, }; localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache)); // Disable splash screen in tests localStorage.setItem('automaker-disable-splash', 'true'); }, { project: [projectId, projectName, projectPath] } ); // Intercept settings API to use our test project instead of the E2E fixture. // Only intercept GET requests - let PUT requests pass through unmodified. await page.route('**/api/settings/global', async (route) => { if (route.request().method() !== 'GET') { await route.continue(); return; } let response: Awaited>; try { response = await route.fetch(); } catch { await route.continue(); return; } if (!response.ok()) { await route.fulfill({ response }); return; } const json = await response.json(); // Override to use our test project if (json.settings) { json.settings.currentProjectId = projectId; json.settings.projects = [ { id: projectId, name: projectName, path: projectPath, lastOpened: new Date().toISOString(), }, ]; } await route.fulfill({ response, json }); }); // Track API calls to /api/settings/project to verify settings are being loaded const settingsApiCalls: Array<{ url: string; method: string; body: string }> = []; page.on('request', (request) => { if (request.url().includes('/api/settings/project') && request.method() === 'POST') { settingsApiCalls.push({ url: request.url(), method: request.method(), body: request.postData() || '', }); } }); await authenticateForTests(page); // Navigate to the app await page.goto('/'); await page.waitForLoadState('load'); await handleLoginScreenIfPresent(page); // Should go straight to board view (not welcome) since we have currentProject await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); // Wait for settings to load by checking API calls await expect(async () => { const calls = settingsApiCalls.filter((call) => call.body.includes(projectPath)); expect(calls.length).toBeGreaterThanOrEqual(1); }).toPass({ timeout: 10000 }); // Verify that the settings API was called for this project const projectSettingsCalls = settingsApiCalls.filter((call) => call.body.includes(projectPath)); // Debug: log all API calls if test fails if (projectSettingsCalls.length < 1) { console.log('Total settings API calls:', settingsApiCalls.length); console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2)); console.log('Looking for path:', projectPath); } expect(projectSettingsCalls.length).toBeGreaterThanOrEqual(1); // Verify settings file exists with correct data const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); expect(loadedSettings.boardBackground).toBeDefined(); expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath); expect(loadedSettings.boardBackground.cardOpacity).toBe(90); expect(loadedSettings.boardBackground.columnOpacity).toBe(70); // Clean up route handlers to avoid "route in flight" errors during teardown await page.unrouteAll({ behavior: 'ignoreErrors' }); // The test passing means: // 1. The useProjectSettingsLoader hook is working // 2. Settings are loaded when app starts with a currentProject // 3. The API call to /api/settings/project is made correctly }); });