diff --git a/apps/frontend/src/__tests__/setup.ts b/apps/frontend/src/__tests__/setup.ts index f891d4b8c..8836a40a4 100644 --- a/apps/frontend/src/__tests__/setup.ts +++ b/apps/frontend/src/__tests__/setup.ts @@ -2,7 +2,8 @@ * Test setup file for Vitest */ import { vi, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, rmSync, existsSync } from 'fs'; +import { mkdirSync, mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; import path from 'path'; // Mock localStorage for tests that need it @@ -57,39 +58,31 @@ if (typeof global.requestAnimationFrame === 'undefined') { }); } -// Test data directory for isolated file operations -export const TEST_DATA_DIR = '/tmp/auto-code-ui-tests'; +// Base directory scoped to this worker process so parallel vitest workers +// never touch each other's files. mkdtemp creates an unpredictable 0700 +// directory, so no other local user can pre-create or tamper with it. +const WORKER_DATA_DIR = mkdtempSync(path.join(tmpdir(), 'auto-code-ui-tests-')); + +// Test data directory for isolated file operations - reassigned to a unique +// directory before each test, and only that directory is ever cleaned up +export let TEST_DATA_DIR = WORKER_DATA_DIR; // Create fresh test directory before each test beforeEach(() => { // Clear localStorage localStorageMock.clear(); - // Use a unique subdirectory per test to avoid race conditions in parallel tests + // Unique subdirectory per test; never delete the shared parent const testId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const _testDir = path.join(TEST_DATA_DIR, testId); - - try { - if (existsSync(TEST_DATA_DIR)) { - rmSync(TEST_DATA_DIR, { recursive: true, force: true }); - } - } catch { - // Ignore errors if directory is in use by another parallel test - // Each test uses unique subdirectory anyway - } - - try { - mkdirSync(TEST_DATA_DIR, { recursive: true }); - mkdirSync(path.join(TEST_DATA_DIR, 'store'), { recursive: true }); - } catch { - // Ignore errors if directory already exists from another parallel test - } + TEST_DATA_DIR = path.join(WORKER_DATA_DIR, testId); + mkdirSync(path.join(TEST_DATA_DIR, 'store'), { recursive: true }); }); -// Clean up test directory after each test +// Clean up this test's directory after each test afterEach(() => { vi.clearAllMocks(); vi.resetModules(); + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); // Mock window.electronAPI for renderer tests diff --git a/apps/frontend/src/main/__tests__/project-store.test.ts b/apps/frontend/src/main/__tests__/project-store.test.ts index bc638eef4..7138178c7 100644 --- a/apps/frontend/src/main/__tests__/project-store.test.ts +++ b/apps/frontend/src/main/__tests__/project-store.test.ts @@ -48,15 +48,11 @@ async function waitForFile(filePath: string, timeout = 2000): Promise { /** * Wait for the ProjectStore's async initialization to complete. * The constructor fires initializeAsync() in the background (fire-and-forget), - * which can race with subsequent method calls on macOS. This helper - * yields enough event-loop ticks for the async init (mkdir + readFile) to finish. + * which can race with subsequent method calls. Awaiting whenReady() is + * deterministic regardless of I/O load, unlike fixed sleeps. */ -async function waitForStoreInit(): Promise { - // Two async I/O ops in initializeAsync (mkdir + readFile) plus microtask overhead. - // Yielding a few times ensures they complete before we proceed. - for (let i = 0; i < 5; i++) { - await new Promise(r => setTimeout(r, 10)); - } +async function waitForStoreInit(store: { whenReady(): Promise }): Promise { + await store.whenReady(); } /** @@ -186,7 +182,7 @@ describe('ProjectStore', () => { it('should persist project to disk', async () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); store.addProject(TEST_PROJECT_PATH); @@ -225,7 +221,7 @@ describe('ProjectStore', () => { it('should persist removal to disk', async () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); // Wait for addProject's async save to complete before removing @@ -313,7 +309,7 @@ describe('ProjectStore', () => { it('should update settings and return updated project', async () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); const updated = store.updateProjectSettings(project.id, { @@ -329,7 +325,7 @@ describe('ProjectStore', () => { it('should update updatedAt timestamp', async () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); const originalUpdatedAt = project.updatedAt; @@ -345,7 +341,7 @@ describe('ProjectStore', () => { it('should persist settings changes', async () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); // Wait for addProject's async save to flush @@ -680,7 +676,7 @@ describe('ProjectStore', () => { const store = new ProjectStore(); // Wait for async initialization to load the file from disk - await waitForStoreInit(); + await waitForStoreInit(store); const projects = store.getProjects(); @@ -698,7 +694,7 @@ describe('ProjectStore', () => { const store = new ProjectStore(); // Wait for async initialization to attempt loading the corrupted file - await waitForStoreInit(); + await waitForStoreInit(store); const projects = store.getProjects(); @@ -725,7 +721,7 @@ describe('ProjectStore', () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); const result = await store.archiveTasks(project.id, ['001-test-task'], '1.0.0'); @@ -775,7 +771,7 @@ describe('ProjectStore', () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); const result = await store.archiveTasks(project.id, ['002-multi-location'], '2.0.0'); @@ -825,7 +821,7 @@ describe('ProjectStore', () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); const result = await store.archiveTasks(project.id, ['003-worktree-only'], '1.0.0'); @@ -843,7 +839,7 @@ describe('ProjectStore', () => { it('should skip non-existent task gracefully', async () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); // Create .auto-claude directory so project is recognized mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude'), { recursive: true }); @@ -866,7 +862,7 @@ describe('ProjectStore', () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); @@ -891,7 +887,7 @@ describe('ProjectStore', () => { it('should return false for non-existent project', async () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const result = await store.archiveTasks('nonexistent-project-id', ['some-task']); @@ -937,7 +933,7 @@ describe('ProjectStore', () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); const result = await store.unarchiveTasks(project.id, ['004-unarchive-test']); @@ -984,7 +980,7 @@ describe('ProjectStore', () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); expect(project.id).toBe(projectId); @@ -1025,7 +1021,7 @@ describe('ProjectStore', () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); expect(project.id).toBe(projectId); @@ -1095,7 +1091,7 @@ describe('ProjectStore', () => { const { ProjectStore } = await import('../project-store'); const store = new ProjectStore(); - await waitForStoreInit(); + await waitForStoreInit(store); const project = store.addProject(TEST_PROJECT_PATH); const tasks = await store.getTasks(project.id); diff --git a/apps/frontend/src/main/__tests__/window-security.test.ts b/apps/frontend/src/main/__tests__/window-security.test.ts index dd118ad9f..37a07d954 100644 --- a/apps/frontend/src/main/__tests__/window-security.test.ts +++ b/apps/frontend/src/main/__tests__/window-security.test.ts @@ -212,6 +212,28 @@ vi.mock("../notification-service", () => ({ }, })); +type CapturedWindowOptions = { webPreferences?: Record } | null; + +/** + * Poll until the BrowserWindow constructor has been invoked (app.whenReady() + * and createWindow() run asynchronously after the module import). Polling is + * deterministic under parallel test load, unlike a fixed sleep. + */ +async function waitForCapturedOptions(timeout = 5000): Promise { + const start = Date.now(); + const getOptions = () => + ( + BrowserWindow as unknown as { getCapturedOptions: () => Record } + ).getCapturedOptions() as CapturedWindowOptions; + + while (Date.now() - start < timeout) { + const options = getOptions(); + if (options !== null) return options; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return getOptions(); +} + describe("BrowserWindow Security Configuration", () => { beforeEach(() => { // Reset captured options before each test @@ -225,13 +247,8 @@ describe("BrowserWindow Security Configuration", () => { // This must happen after all mocks are set up await import("../index"); - // Wait a bit for app.whenReady() to resolve and createWindow() to be called - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Get the captured options from BrowserWindow constructor - const capturedOptions = ( - BrowserWindow as unknown as { getCapturedOptions: () => Record } - ).getCapturedOptions() as { webPreferences?: Record } | null; + // Wait for app.whenReady() to resolve and createWindow() to be called + const capturedOptions = await waitForCapturedOptions(); // Verify options were captured expect(capturedOptions).not.toBeNull(); @@ -249,11 +266,7 @@ describe("BrowserWindow Security Configuration", () => { await import("../index"); // Wait for window creation - await new Promise((resolve) => setTimeout(resolve, 100)); - - const capturedOptions = ( - BrowserWindow as unknown as { getCapturedOptions: () => Record } - ).getCapturedOptions() as { webPreferences?: Record } | null; + const capturedOptions = await waitForCapturedOptions(); expect(capturedOptions).not.toBeNull(); expect(capturedOptions?.webPreferences).toBeDefined(); @@ -266,11 +279,7 @@ describe("BrowserWindow Security Configuration", () => { await import("../index"); // Wait for window creation - await new Promise((resolve) => setTimeout(resolve, 100)); - - const capturedOptions = ( - BrowserWindow as unknown as { getCapturedOptions: () => Record } - ).getCapturedOptions() as { webPreferences?: Record } | null; + const capturedOptions = await waitForCapturedOptions(); expect(capturedOptions).not.toBeNull(); const webPreferences = capturedOptions?.webPreferences; diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index cc85552b9..62a492ac0 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -46,6 +46,7 @@ export class ProjectStore { */ private consecutiveFailures = 0; private static readonly MAX_FAILURES_BEFORE_WARNING = 3; + private readonly initPromise: Promise; constructor() { // Store in app's userData directory @@ -57,11 +58,20 @@ export class ProjectStore { this.data = { projects: [], settings: {} }; // Start async initialization in background - this.initializeAsync().catch(error => { + this.initPromise = this.initializeAsync().catch(error => { console.error('[ProjectStore] Failed to initialize store:', error); }); } + /** + * Resolves once the background initialization (mkdir + load from disk) + * has finished. Callers that need the on-disk data loaded should await + * this before reading or mutating the store. + */ + whenReady(): Promise { + return this.initPromise; + } + /** * Async initialization - ensures directory exists and loads data */ diff --git a/apps/frontend/src/security-allowlist-e2e.spec.ts b/apps/frontend/src/security-allowlist-e2e.spec.ts index 341b5727a..90bc81f69 100644 --- a/apps/frontend/src/security-allowlist-e2e.spec.ts +++ b/apps/frontend/src/security-allowlist-e2e.spec.ts @@ -12,12 +12,15 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { promises as fs } from 'fs'; +import { mkdtempSync, promises as fs } from 'fs'; +import { tmpdir } from 'os'; import path from 'path'; import type { SecurityProfile, CommandAllowlistEntry } from './shared/types/security'; -// Test data paths -const TEST_DATA_DIR = path.join(process.cwd(), 'test-data'); +// Test data paths - mkdtemp gives this spec file its own unpredictable +// 0700 directory, so parallel vitest workers running the other security +// e2e specs never share it (and no other local user can pre-create it) +const TEST_DATA_DIR = mkdtempSync(path.join(tmpdir(), 'security-allowlist-e2e-')); const TEST_PROFILE_PATH = path.join(TEST_DATA_DIR, '.auto-claude-security.json'); /** diff --git a/apps/frontend/src/security-audit-log-e2e.spec.ts b/apps/frontend/src/security-audit-log-e2e.spec.ts index 70e5a3a33..924dd20d5 100644 --- a/apps/frontend/src/security-audit-log-e2e.spec.ts +++ b/apps/frontend/src/security-audit-log-e2e.spec.ts @@ -11,12 +11,15 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { promises as fs } from 'fs'; +import { mkdtempSync, promises as fs } from 'fs'; +import { tmpdir } from 'os'; import path from 'path'; import type { SecurityAuditLog } from './shared/types/security'; -// Test data paths -const TEST_DATA_DIR = path.join(process.cwd(), 'test-data'); +// Test data paths - mkdtemp gives this spec file its own unpredictable +// 0700 directory, so parallel vitest workers running the other security +// e2e specs never share it (and no other local user can pre-create it) +const TEST_DATA_DIR = mkdtempSync(path.join(tmpdir(), 'security-audit-log-e2e-')); const TEST_AUDIT_LOG_PATH = path.join(TEST_DATA_DIR, '.auto-claude-audit.json'); /** diff --git a/apps/frontend/src/security-export-e2e.spec.ts b/apps/frontend/src/security-export-e2e.spec.ts index 891a2c332..328ba3a1b 100644 --- a/apps/frontend/src/security-export-e2e.spec.ts +++ b/apps/frontend/src/security-export-e2e.spec.ts @@ -12,12 +12,15 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { promises as fs } from 'fs'; +import { mkdtempSync, promises as fs } from 'fs'; +import { tmpdir } from 'os'; import path from 'path'; import type { SecurityProfile, SecurityExport, SecurityAuditLog } from './shared/types/security'; -// Test data paths -const TEST_DATA_DIR = path.join(process.cwd(), 'test-data'); +// Test data paths - mkdtemp gives this spec file its own unpredictable +// 0700 directory, so parallel vitest workers running the other security +// e2e specs never share it (and no other local user can pre-create it) +const TEST_DATA_DIR = mkdtempSync(path.join(tmpdir(), 'security-export-e2e-')); const TEST_PROFILE_PATH = path.join(TEST_DATA_DIR, '.auto-claude-security.json'); const TEST_AUDIT_LOG_PATH = path.join(TEST_DATA_DIR, '.auto-claude-audit.log'); const EXPORT_OUTPUT_DIR = path.join(TEST_DATA_DIR, 'exports'); diff --git a/apps/frontend/src/security-level-presets-e2e.spec.ts b/apps/frontend/src/security-level-presets-e2e.spec.ts index dfc159b9e..1543f76a9 100644 --- a/apps/frontend/src/security-level-presets-e2e.spec.ts +++ b/apps/frontend/src/security-level-presets-e2e.spec.ts @@ -11,12 +11,15 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { promises as fs } from 'fs'; +import { mkdtempSync, promises as fs } from 'fs'; +import { tmpdir } from 'os'; import path from 'path'; import type { SecurityProfile, SecurityLevel, CommandAllowlistEntry } from './shared/types/security'; -// Test data paths -const TEST_DATA_DIR = path.join(process.cwd(), 'test-data'); +// Test data paths - mkdtemp gives this spec file its own unpredictable +// 0700 directory, so parallel vitest workers running the other security +// e2e specs never share it (and no other local user can pre-create it) +const TEST_DATA_DIR = mkdtempSync(path.join(tmpdir(), 'security-level-presets-e2e-')); const TEST_PROFILE_PATH = path.join(TEST_DATA_DIR, '.auto-claude-security.json'); // Security level preset configurations (matching SecuritySettings.tsx) diff --git a/apps/frontend/src/security-warnings-e2e.spec.ts b/apps/frontend/src/security-warnings-e2e.spec.ts index 4f7f4f653..42b9f1f98 100644 --- a/apps/frontend/src/security-warnings-e2e.spec.ts +++ b/apps/frontend/src/security-warnings-e2e.spec.ts @@ -11,12 +11,15 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { promises as fs } from 'fs'; +import { mkdtempSync, promises as fs } from 'fs'; +import { tmpdir } from 'os'; import path from 'path'; import type { SecurityProfile, CommandAllowlistEntry, SecurityAuditLog } from './shared/types/security'; -// Test data paths -const TEST_DATA_DIR = path.join(process.cwd(), 'test-data'); +// Test data paths - mkdtemp gives this spec file its own unpredictable +// 0700 directory, so parallel vitest workers running the other security +// e2e specs never share it (and no other local user can pre-create it) +const TEST_DATA_DIR = mkdtempSync(path.join(tmpdir(), 'security-warnings-e2e-')); const TEST_PROFILE_PATH = path.join(TEST_DATA_DIR, '.auto-claude-security.json'); const TEST_AUDIT_LOG_PATH = path.join(TEST_DATA_DIR, '.auto-claude-audit.json');