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
37 changes: 15 additions & 22 deletions apps/frontend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 21 additions & 25 deletions apps/frontend/src/main/__tests__/project-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,11 @@ async function waitForFile(filePath: string, timeout = 2000): Promise<string> {
/**
* 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<void> {
// 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<void> }): Promise<void> {
await store.whenReady();
}

/**
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand All @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -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 });
Expand All @@ -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);

Expand All @@ -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']);

Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
43 changes: 26 additions & 17 deletions apps/frontend/src/main/__tests__/window-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,28 @@ vi.mock("../notification-service", () => ({
},
}));

type CapturedWindowOptions = { webPreferences?: Record<string, unknown> } | 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<CapturedWindowOptions> {
const start = Date.now();
const getOptions = () =>
(
BrowserWindow as unknown as { getCapturedOptions: () => Record<string, unknown> }
).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
Expand All @@ -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<string, unknown> }
).getCapturedOptions() as { webPreferences?: Record<string, unknown> } | null;
// Wait for app.whenReady() to resolve and createWindow() to be called
const capturedOptions = await waitForCapturedOptions();

// Verify options were captured
expect(capturedOptions).not.toBeNull();
Expand All @@ -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<string, unknown> }
).getCapturedOptions() as { webPreferences?: Record<string, unknown> } | null;
const capturedOptions = await waitForCapturedOptions();

expect(capturedOptions).not.toBeNull();
expect(capturedOptions?.webPreferences).toBeDefined();
Expand All @@ -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<string, unknown> }
).getCapturedOptions() as { webPreferences?: Record<string, unknown> } | null;
const capturedOptions = await waitForCapturedOptions();

expect(capturedOptions).not.toBeNull();
const webPreferences = capturedOptions?.webPreferences;
Expand Down
12 changes: 11 additions & 1 deletion apps/frontend/src/main/project-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
*/
private consecutiveFailures = 0;
private static readonly MAX_FAILURES_BEFORE_WARNING = 3;
private readonly initPromise: Promise<void>;

constructor() {
// Store in app's userData directory
Expand All @@ -57,11 +58,20 @@
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);
});

Check failure on line 63 in apps/frontend/src/main/project-store.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this asynchronous operation outside of the constructor.

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ9VAolhOFmXP_NXOsMr&open=AZ9VAolhOFmXP_NXOsMr&pullRequest=416
}

/**
* 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<void> {
return this.initPromise;
}

/**
* Async initialization - ensures directory exists and loads data
*/
Expand Down
9 changes: 6 additions & 3 deletions apps/frontend/src/security-allowlist-e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

/**
Expand Down
9 changes: 6 additions & 3 deletions apps/frontend/src/security-audit-log-e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

/**
Expand Down
9 changes: 6 additions & 3 deletions apps/frontend/src/security-export-e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading