/** * 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-local/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.join(TEST_TEMP_DIR, projectAName); const projectBPath = path.join(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-local directory for project A with background settings const automakerDirA = path.join(projectAPath, '.automaker-local'); 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-local directory for project B (no background) const automakerDirB = path.join(projectBPath, '.automaker-local'); 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, maxConcurrency: 3, }; localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache)); localStorage.setItem('automaker-disable-splash', 'true'); }, { projects: [projectA, projectB], versions: { APP_STORE: 2, SETUP_STORE: 1 } } ); // Intercept settings API BEFORE authentication to ensure our test projects // are consistently returned by the server. Only intercept GET requests - // let PUT requests (settings saves) pass through unmodified. await page.route('**/api/settings/global', async (route) => { if (route.request().method() !== 'GET') { await route.continue(); return; } const response = await route.fetch(); const json = await response.json(); if (json.settings) { json.settings.currentProjectId = projectAId; json.settings.projects = [projectA, projectB]; } 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 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 }); // CRITICAL: Wait for settings to be loaded (useProjectSettingsLoader hook) // This ensures the background settings are fetched from the server await page.waitForTimeout(2000); // Check if background settings were applied by checking the store // We can't directly access React state, so we'll verify via DOM/CSS const boardView = page.locator('[data-testid="board-view"]'); await expect(boardView).toBeVisible(); // Wait for initial project load to stabilize await page.waitForTimeout(500); // 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.waitForTimeout(300); } // Switch to project B (no background) const projectSelector = page.locator('[data-testid="project-dropdown-trigger"]'); await expect(projectSelector).toBeVisible({ timeout: 5000 }); await projectSelector.click(); // Wait for dropdown to be visible await expect(page.locator('[data-testid="project-dropdown-content"]')).toBeVisible({ timeout: 5000, }); const projectPickerB = page.locator(`[data-testid="project-item-${projectBId}"]`); await expect(projectPickerB).toBeVisible({ timeout: 5000 }); await projectPickerB.click(); // Wait for project B to load await expect( page.locator('[data-testid="project-dropdown-trigger"]').getByText(projectBName) ).toBeVisible({ timeout: 5000 }); // Wait a bit for project B to fully load before switching await page.waitForTimeout(500); // Switch back to project A await projectSelector.click(); // Wait for dropdown to be visible await expect(page.locator('[data-testid="project-dropdown-content"]')).toBeVisible({ timeout: 5000, }); const projectPickerA = page.locator(`[data-testid="project-item-${projectAId}"]`); await expect(projectPickerA).toBeVisible({ timeout: 5000 }); await projectPickerA.click(); // Verify we're back on project A await expect( page.locator('[data-testid="project-dropdown-trigger"]').getByText(projectAName) ).toBeVisible({ timeout: 5000 }); // CRITICAL: Wait for settings to be loaded again await page.waitForTimeout(2000); // 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-local with background settings const automakerDir = path.join(projectPath, '.automaker-local'); 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; } const response = await route.fetch(); 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 await page.waitForTimeout(2000); // 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 }); });