diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..487f3fd
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,43 @@
+import { defineConfig, devices } from "@playwright/test";
+
+/**
+ * Playwright configuration for Mission Control E2E tests
+ *
+ * For Tauri desktop app testing, we use the webview URL directly
+ * rather than launching the full native app wrapper.
+ */
+export default defineConfig({
+ testDir: "./tests/e2e",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: "html",
+
+ use: {
+ // Base URL for webview testing
+ baseURL: "http://localhost:1420",
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ },
+
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ // Note: Tauri uses WebKit on macOS, so we test against Safari-like behavior
+ {
+ name: "webkit",
+ use: { ...devices["Desktop Safari"] },
+ },
+ ],
+
+ // Run the Vite dev server before tests
+ webServer: {
+ command: "npm run dev",
+ url: "http://localhost:1420",
+ reuseExistingServer: !process.env.CI,
+ timeout: 120000,
+ },
+});
diff --git a/tests/components/App.test.tsx b/tests/components/App.test.tsx
new file mode 100644
index 0000000..6f1d517
--- /dev/null
+++ b/tests/components/App.test.tsx
@@ -0,0 +1,27 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import App from "@/App";
+
+describe("App", () => {
+ it("renders the main heading", () => {
+ render();
+
+ expect(screen.getByText("Mission Control")).toBeInTheDocument();
+ });
+
+ it("renders the tagline", () => {
+ render();
+
+ expect(
+ screen.getByText("What should you be doing right now?")
+ ).toBeInTheDocument();
+ });
+
+ it("renders the focus placeholder", () => {
+ render();
+
+ expect(
+ screen.getByText("THE ONE THING will appear here")
+ ).toBeInTheDocument();
+ });
+});
diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts
new file mode 100644
index 0000000..d657bfd
--- /dev/null
+++ b/tests/e2e/app.spec.ts
@@ -0,0 +1,25 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("Mission Control App", () => {
+ test("displays the main heading", async ({ page }) => {
+ await page.goto("/");
+
+ await expect(page.getByText("Mission Control")).toBeVisible();
+ });
+
+ test("displays the tagline", async ({ page }) => {
+ await page.goto("/");
+
+ await expect(
+ page.getByText("What should you be doing right now?")
+ ).toBeVisible();
+ });
+
+ test("has dark mode styling", async ({ page }) => {
+ await page.goto("/");
+
+ // Check that the page has dark background (zinc-900)
+ const body = page.locator("body");
+ await expect(body).toHaveClass(/bg-surface/);
+ });
+});
diff --git a/tests/mocks/tauri-api.ts b/tests/mocks/tauri-api.ts
new file mode 100644
index 0000000..adf5d35
--- /dev/null
+++ b/tests/mocks/tauri-api.ts
@@ -0,0 +1,78 @@
+/**
+ * Mock implementation of @tauri-apps/api for testing
+ *
+ * This allows tests to run without a Tauri runtime.
+ */
+
+import { vi } from "vitest";
+
+// Store for mock responses - tests can override these
+export const mockResponses: Record = {};
+
+// Mock invoke function
+export const invoke = vi.fn(async (cmd: string, _args?: unknown) => {
+ if (cmd in mockResponses) {
+ return mockResponses[cmd];
+ }
+
+ // Default responses
+ switch (cmd) {
+ case "greet":
+ return "Hello from mock Tauri!";
+ case "get_lore_status":
+ return {
+ last_sync: null,
+ is_healthy: true,
+ message: "Mock lore status",
+ };
+ default:
+ throw new Error(`Mock not implemented for command: ${cmd}`);
+ }
+});
+
+// Helper to set mock responses in tests
+export function setMockResponse(cmd: string, response: unknown): void {
+ mockResponses[cmd] = response;
+}
+
+// Helper to reset all mocks
+export function resetMocks(): void {
+ invoke.mockClear();
+ Object.keys(mockResponses).forEach((key) => delete mockResponses[key]);
+}
+
+// Mock event listener
+export const listen = vi.fn(
+ async (_event: string, _handler: (payload: unknown) => void) => {
+ // Return unlisten function
+ return vi.fn();
+ }
+);
+
+// Mock event emitter
+export const emit = vi.fn(async (_event: string, _payload?: unknown) => {});
+
+// Core module exports
+export const core = {
+ invoke,
+};
+
+// Event module exports
+export const event = {
+ listen,
+ emit,
+};
+
+// Window module mock
+export const window = {
+ getCurrent: vi.fn(() => ({
+ label: "main",
+ listen: vi.fn(),
+ emit: vi.fn(),
+ close: vi.fn(),
+ hide: vi.fn(),
+ show: vi.fn(),
+ isVisible: vi.fn(async () => true),
+ setTitle: vi.fn(),
+ })),
+};
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 0000000..980484b
--- /dev/null
+++ b/tests/setup.ts
@@ -0,0 +1,25 @@
+import "@testing-library/jest-dom/vitest";
+
+// Mock window.matchMedia for components that use media queries
+Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+// Mock ResizeObserver for components that use it
+class MockResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+window.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..59ce37d
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,30 @@
+///
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import path from "path";
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ environment: "jsdom",
+ setupFiles: ["./tests/setup.ts"],
+ include: ["tests/**/*.{test,spec}.{ts,tsx}"],
+ exclude: ["tests/e2e/**", "**/node_modules/**"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "json", "html"],
+ include: ["src/**/*.{ts,tsx}"],
+ exclude: ["src/main.tsx", "src/**/*.d.ts"],
+ },
+ // Mock Tauri APIs
+ alias: {
+ "@tauri-apps/api": path.resolve(__dirname, "./tests/mocks/tauri-api.ts"),
+ },
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});