Files
automaker/apps/ui/tests/settings/settings-startup-sync-race.spec.ts
webdevcody a695d0db7b feat: enhance OpenCode provider tests and UI setup
- Updated unit tests for OpenCode provider to include new authentication indicators.
- Refactored ProvidersSetupStep component by removing unnecessary UI elements for better clarity.
- Improved board background persistence tests by utilizing a setup function for initializing app state.
- Enhanced settings synchronization tests to ensure proper handling of login and app state.

These changes improve the testing framework and user interface for OpenCode integration, ensuring a smoother setup and authentication process.
2026-01-09 10:08:38 -05:00

150 lines
5.8 KiB
TypeScript

/**
* Settings Startup Race Regression Test
*
* Repro (historical bug):
* - UI verifies session successfully
* - Initial GET /api/settings/global fails transiently (backend still starting)
* - UI unblocks settings sync anyway and can push default empty state to server
* - Server persists projects: [] (and other defaults), wiping settings.json
*
* This test forces the first few /api/settings/global requests to fail and asserts that
* the server-side settings.json is NOT overwritten while the UI is waiting to hydrate.
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { authenticateForTests, handleLoginScreenIfPresent } from '../utils';
const SETTINGS_PATH = path.resolve(process.cwd(), '../server/data/settings.json');
const WORKSPACE_ROOT = path.resolve(process.cwd(), '../..');
const FIXTURE_PROJECT_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
test.describe('Settings startup sync race', () => {
let originalSettingsJson: string;
test.beforeAll(() => {
originalSettingsJson = fs.readFileSync(SETTINGS_PATH, 'utf-8');
const settings = JSON.parse(originalSettingsJson) as Record<string, unknown>;
settings.projects = [
{
id: `e2e-project-${Date.now()}`,
name: 'E2E Project (settings race)',
path: FIXTURE_PROJECT_PATH,
lastOpened: new Date().toISOString(),
theme: 'dark',
},
];
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
});
test.afterAll(() => {
// Restore original settings.json to avoid polluting other tests/dev state
fs.writeFileSync(SETTINGS_PATH, originalSettingsJson);
});
test('does not overwrite projects when /api/settings/global is temporarily unavailable', async ({
page,
}) => {
// Gate the real settings request so we can assert file contents before allowing hydration.
let requestCount = 0;
let allowSettingsRequestResolve: (() => void) | null = null;
const allowSettingsRequest = new Promise<void>((resolve) => {
allowSettingsRequestResolve = resolve;
});
let sawThreeFailuresResolve: (() => void) | null = null;
const sawThreeFailures = new Promise<void>((resolve) => {
sawThreeFailuresResolve = resolve;
});
await page.route('**/api/settings/global', async (route) => {
requestCount++;
if (requestCount <= 3) {
if (requestCount === 3) {
sawThreeFailuresResolve?.();
}
await route.abort('failed');
return;
}
// Keep the 4th+ request pending until the test explicitly allows it.
await allowSettingsRequest;
await route.continue();
});
// Ensure we are authenticated (session cookie) before loading the app.
await authenticateForTests(page);
await page.goto('/');
// Wait until we have forced a few failures.
await sawThreeFailures;
// At this point, the UI should NOT have written defaults back to the server.
const settingsAfterFailures = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) as {
projects?: Array<{ path?: string }>;
};
expect(settingsAfterFailures.projects?.length).toBeGreaterThan(0);
expect(settingsAfterFailures.projects?.[0]?.path).toBe(FIXTURE_PROJECT_PATH);
// Allow the settings request to succeed so the app can hydrate and proceed.
allowSettingsRequestResolve?.();
// App should eventually render a main view after settings hydration.
await page
.locator('[data-testid="welcome-view"], [data-testid="board-view"]')
.first()
.waitFor({ state: 'visible', timeout: 30000 });
// Verify settings.json still contains the project after hydration completes.
const settingsAfterHydration = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) as {
projects?: Array<{ path?: string }>;
};
expect(settingsAfterHydration.projects?.length).toBeGreaterThan(0);
expect(settingsAfterHydration.projects?.[0]?.path).toBe(FIXTURE_PROJECT_PATH);
});
test('does not wipe projects during logout transition', async ({ page }) => {
// Ensure authenticated and app is loaded at least to welcome/board.
await authenticateForTests(page);
await page.goto('/');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await page
.locator('[data-testid="welcome-view"], [data-testid="board-view"]')
.first()
.waitFor({ state: 'visible', timeout: 30000 });
// Confirm settings.json currently has projects (precondition).
const beforeLogout = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) as {
projects?: Array<unknown>;
};
expect(beforeLogout.projects?.length).toBeGreaterThan(0);
// Navigate to settings, then to Account section (logout button is only visible there)
await page.goto('/settings');
// Wait for settings view to load, then click on Account section
await page.locator('button:has-text("Account")').first().click();
// Wait for account section to be visible before clicking logout
await page
.locator('[data-testid="logout-button"]')
.waitFor({ state: 'visible', timeout: 10000 });
await page.locator('[data-testid="logout-button"]').click();
// Ensure we landed on logged-out or login (either is acceptable).
// Note: The page uses curly apostrophe (') so we match the heading role instead
await page
.getByRole('heading', { name: /logged out/i })
.or(page.locator('text=Authentication Required'))
.first()
.waitFor({ state: 'visible', timeout: 30000 });
// The server settings file should still have projects after logout.
const afterLogout = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) as {
projects?: Array<unknown>;
};
expect(afterLogout.projects?.length).toBeGreaterThan(0);
});
});