diff --git a/package-lock.json b/package-lock.json
index 4854062..bbe3d1c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
"@tauri-apps/cli": "^2.3.0",
+ "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -1979,6 +1980,26 @@
"@tauri-apps/api": "^2.10.1"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -2048,6 +2069,13 @@
"@testing-library/dom": ">=7.21.4"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2653,6 +2681,16 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -3183,6 +3221,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
@@ -4130,6 +4175,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4713,6 +4768,34 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4765,6 +4848,13 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
diff --git a/package.json b/package.json
index ca49653..d527292 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
"@tauri-apps/cli": "^2.3.0",
+ "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
diff --git a/playwright.config.ts b/playwright.config.ts
index 487f3fd..bb1fdd4 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -26,11 +26,6 @@ export default defineConfig({
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
diff --git a/tests/components/App.test.tsx b/tests/components/App.test.tsx
index 6f1d517..db19911 100644
--- a/tests/components/App.test.tsx
+++ b/tests/components/App.test.tsx
@@ -1,27 +1,40 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import App from "@/App";
+import { useFocusStore } from "@/stores/focus-store";
+import { useNavStore } from "@/stores/nav-store";
describe("App", () => {
- it("renders the main heading", () => {
- render();
-
- expect(screen.getByText("Mission Control")).toBeInTheDocument();
+ beforeEach(() => {
+ useNavStore.setState({ activeView: "focus" });
+ useFocusStore.setState({
+ current: null,
+ queue: [],
+ isLoading: false,
+ error: null,
+ });
});
- it("renders the tagline", () => {
+ it("renders the app shell with navigation", () => {
render();
-
- expect(
- screen.getByText("What should you be doing right now?")
- ).toBeInTheDocument();
+ expect(screen.getByText("Focus")).toBeInTheDocument();
+ expect(screen.getByText("Queue")).toBeInTheDocument();
});
- it("renders the focus placeholder", () => {
+ it("renders the focus view with empty state by default", () => {
render();
+ expect(screen.getByText(/all clear/i)).toBeInTheDocument();
+ });
- expect(
- screen.getByText("THE ONE THING will appear here")
- ).toBeInTheDocument();
+ it("shows loading state", () => {
+ useFocusStore.setState({ isLoading: true });
+ render();
+ expect(screen.getByText(/loading/i)).toBeInTheDocument();
+ });
+
+ it("shows error state", () => {
+ useFocusStore.setState({ error: "Connection failed" });
+ render();
+ expect(screen.getByText("Connection failed")).toBeInTheDocument();
});
});
diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx
new file mode 100644
index 0000000..689e95f
--- /dev/null
+++ b/tests/components/AppShell.test.tsx
@@ -0,0 +1,104 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { AppShell } from "@/components/AppShell";
+import { useNavStore } from "@/stores/nav-store";
+import { useFocusStore } from "@/stores/focus-store";
+import { useCaptureStore } from "@/stores/capture-store";
+import { simulateEvent, resetMocks } from "../mocks/tauri-api";
+import { makeFocusItem } from "../helpers/fixtures";
+
+describe("AppShell", () => {
+ beforeEach(() => {
+ useNavStore.setState({ activeView: "focus" });
+ useFocusStore.setState({
+ current: null,
+ queue: [],
+ isLoading: false,
+ error: null,
+ });
+ useCaptureStore.setState({
+ isOpen: false,
+ isSubmitting: false,
+ lastCapturedId: null,
+ error: null,
+ });
+ resetMocks();
+ });
+
+ it("renders navigation tabs", () => {
+ render();
+ expect(screen.getByText("Focus")).toBeInTheDocument();
+ expect(screen.getByText("Queue")).toBeInTheDocument();
+ expect(screen.getByText("Inbox")).toBeInTheDocument();
+ });
+
+ it("shows Focus view by default", () => {
+ render();
+ expect(screen.getByText(/all clear/i)).toBeInTheDocument();
+ });
+
+ it("switches to Queue view when Queue tab is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText("Queue"));
+ expect(await screen.findByText(/no items/i)).toBeInTheDocument();
+ });
+
+ it("switches to Inbox placeholder", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText("Inbox"));
+ expect(await screen.findByText(/coming in Phase 4b/i)).toBeInTheDocument();
+ });
+
+ it("shows queue count badge when items exist", () => {
+ useFocusStore.setState({
+ current: makeFocusItem({ id: "a" }),
+ queue: [makeFocusItem({ id: "b" }), makeFocusItem({ id: "c" })],
+ });
+
+ render();
+ expect(screen.getByText("3")).toBeInTheDocument();
+ });
+
+ it("opens quick capture overlay on global shortcut event", async () => {
+ render();
+
+ act(() => {
+ simulateEvent("global-shortcut-triggered", "quick-capture");
+ });
+
+ expect(useCaptureStore.getState().isOpen).toBe(true);
+ expect(screen.getByPlaceholderText("Capture a thought...")).toBeInTheDocument();
+ });
+
+ it("clicking queue item sets focus and switches to focus view", async () => {
+ const user = userEvent.setup();
+
+ useFocusStore.setState({
+ current: makeFocusItem({ id: "current", title: "Current" }),
+ queue: [
+ makeFocusItem({
+ id: "target",
+ type: "issue",
+ title: "Target item",
+ }),
+ ],
+ });
+
+ render();
+
+ // Navigate to queue and wait for transition
+ await user.click(screen.getByText("Queue"));
+ const targetItem = await screen.findByText("Target item");
+ // Click on the target item
+ await user.click(targetItem);
+
+ // Should switch back to focus view with the target as current
+ expect(useFocusStore.getState().current?.id).toBe("target");
+ expect(useNavStore.getState().activeView).toBe("focus");
+ });
+});
diff --git a/tests/components/BatchMode.test.tsx b/tests/components/BatchMode.test.tsx
new file mode 100644
index 0000000..a474167
--- /dev/null
+++ b/tests/components/BatchMode.test.tsx
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { BatchMode } from "@/components/BatchMode";
+import { useBatchStore } from "@/stores/batch-store";
+import { makeFocusItem } from "../helpers/fixtures";
+
+describe("BatchMode", () => {
+ const onOpenUrl = vi.fn();
+ const onExit = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useBatchStore.getState().exitBatch();
+ });
+
+ function startBatchWith(count: number) {
+ const items = Array.from({ length: count }, (_, i) =>
+ makeFocusItem({
+ id: `r${i + 1}`,
+ type: "mr_review",
+ title: `Review MR !${900 + i}`,
+ iid: 900 + i,
+ })
+ );
+ useBatchStore.getState().startBatch(items, "CODE REVIEWS");
+ }
+
+ describe("rendering", () => {
+ it("shows the batch label", () => {
+ startBatchWith(3);
+ render();
+ expect(screen.getByText(/CODE REVIEWS/)).toBeInTheDocument();
+ });
+
+ it("shows progress (1 of N)", () => {
+ startBatchWith(4);
+ render();
+ expect(screen.getByText(/1 of 4/)).toBeInTheDocument();
+ });
+
+ it("shows the current item title", () => {
+ startBatchWith(3);
+ render();
+ expect(screen.getByText("Review MR !900")).toBeInTheDocument();
+ });
+
+ it("shows item metadata", () => {
+ startBatchWith(2);
+ render();
+ // Metadata line contains IID and project
+ const metaLine = screen.getByText((_content, el) => {
+ return (
+ el?.tagName === "P" &&
+ Boolean(el.textContent?.includes("!900")) &&
+ Boolean(el.textContent?.includes("platform/core"))
+ );
+ });
+ expect(metaLine).toBeInTheDocument();
+ });
+
+ it("shows action buttons", () => {
+ startBatchWith(2);
+ render();
+ expect(
+ screen.getByRole("button", { name: /open in gl/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /done/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /skip/i })
+ ).toBeInTheDocument();
+ });
+
+ it("shows ESC to exit hint", () => {
+ startBatchWith(2);
+ render();
+ expect(screen.getByText(/ESC to exit/i)).toBeInTheDocument();
+ });
+ });
+
+ describe("actions", () => {
+ it("Open in GL calls onOpenUrl with current item URL", async () => {
+ const user = userEvent.setup();
+ startBatchWith(2);
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: /open in gl/i })
+ );
+ expect(onOpenUrl).toHaveBeenCalledWith(
+ "https://gitlab.com/platform/core/-/merge_requests/847"
+ );
+ });
+
+ it("Done marks item and advances to next", async () => {
+ const user = userEvent.setup();
+ startBatchWith(3);
+ render();
+
+ await user.click(screen.getByRole("button", { name: /done/i }));
+
+ expect(screen.getByText(/2 of 3/)).toBeInTheDocument();
+ // Wait for AnimatePresence to swap items
+ expect(await screen.findByText("Review MR !901")).toBeInTheDocument();
+ });
+
+ it("Skip marks item and advances to next", async () => {
+ const user = userEvent.setup();
+ startBatchWith(3);
+ render();
+
+ await user.click(screen.getByRole("button", { name: /skip/i }));
+
+ expect(screen.getByText(/2 of 3/)).toBeInTheDocument();
+ });
+ });
+
+ describe("keyboard shortcuts", () => {
+ it("Cmd+D triggers Done", async () => {
+ const user = userEvent.setup();
+ startBatchWith(2);
+ render();
+
+ await user.keyboard("{Meta>}d{/Meta}");
+
+ expect(screen.getByText(/2 of 2/)).toBeInTheDocument();
+ });
+
+ it("Cmd+S triggers Skip", async () => {
+ const user = userEvent.setup();
+ startBatchWith(2);
+ render();
+
+ await user.keyboard("{Meta>}s{/Meta}");
+
+ expect(screen.getByText(/2 of 2/)).toBeInTheDocument();
+ });
+
+ it("Escape exits batch mode", async () => {
+ const user = userEvent.setup();
+ startBatchWith(2);
+ render();
+
+ await user.keyboard("{Escape}");
+ expect(onExit).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe("completion", () => {
+ it("shows celebration when all items are processed", async () => {
+ const user = userEvent.setup();
+ startBatchWith(2);
+ render();
+
+ await user.click(screen.getByRole("button", { name: /done/i }));
+ await user.click(screen.getByRole("button", { name: /done/i }));
+
+ expect(screen.getByText(/all done/i)).toBeInTheDocument();
+ expect(screen.getByText(/2.*completed/i)).toBeInTheDocument();
+ });
+
+ it("shows completed and skipped counts in celebration", async () => {
+ const user = userEvent.setup();
+ startBatchWith(3);
+ render();
+
+ await user.click(screen.getByRole("button", { name: /done/i }));
+ await user.click(screen.getByRole("button", { name: /skip/i }));
+ await user.click(screen.getByRole("button", { name: /done/i }));
+
+ expect(screen.getByText(/2.*completed/i)).toBeInTheDocument();
+ expect(screen.getByText(/1.*skipped/i)).toBeInTheDocument();
+ });
+
+ it("celebration has a button to exit", async () => {
+ const user = userEvent.setup();
+ startBatchWith(1);
+ render();
+
+ await user.click(screen.getByRole("button", { name: /done/i }));
+
+ const exitBtn = screen.getByRole("button", { name: /back to focus/i });
+ await user.click(exitBtn);
+ expect(onExit).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/tests/components/FocusCard.test.tsx b/tests/components/FocusCard.test.tsx
new file mode 100644
index 0000000..31d5747
--- /dev/null
+++ b/tests/components/FocusCard.test.tsx
@@ -0,0 +1,154 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { FocusCard } from "@/components/FocusCard";
+import type { FocusItem } from "@/lib/types";
+import { makeFocusItem as makeItem } from "../helpers/fixtures";
+
+/** FocusCard tests use a richer default with context quote and requestedBy set. */
+function makeFocusItem(overrides: Partial = {}): FocusItem {
+ return makeItem({
+ contextQuote: "Can you take a look? I need this for the release tomorrow",
+ requestedBy: "sarah",
+ ...overrides,
+ });
+}
+
+describe("FocusCard", () => {
+ const onStart = vi.fn();
+ const onDefer1h = vi.fn();
+ const onDeferTomorrow = vi.fn();
+ const onSkip = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ function renderCard(item: FocusItem = makeFocusItem()) {
+ return render(
+
+ );
+ }
+
+ describe("rendering", () => {
+ it("displays the item title", () => {
+ renderCard();
+ expect(
+ screen.getByText("Fix authentication token refresh logic")
+ ).toBeInTheDocument();
+ });
+
+ it("displays the type badge", () => {
+ renderCard();
+ expect(screen.getByText(/MR REVIEW/i)).toBeInTheDocument();
+ });
+
+ it("displays the project path and IID", () => {
+ renderCard();
+ expect(screen.getByText(/!847/)).toBeInTheDocument();
+ expect(screen.getByText(/platform\/core/)).toBeInTheDocument();
+ });
+
+ it("displays the context quote when present", () => {
+ renderCard();
+ expect(
+ screen.getByText(/Can you take a look/)
+ ).toBeInTheDocument();
+ });
+
+ it("displays who requested attention", () => {
+ renderCard();
+ expect(screen.getByText(/@sarah/)).toBeInTheDocument();
+ });
+
+ it("hides context section when no quote", () => {
+ renderCard(makeFocusItem({ contextQuote: null, requestedBy: null }));
+ expect(screen.queryByText(/@/)).not.toBeInTheDocument();
+ });
+
+ it("shows issue type badge for issues", () => {
+ renderCard(makeFocusItem({ type: "issue", iid: 42 }));
+ expect(screen.getByText(/ISSUE/i)).toBeInTheDocument();
+ expect(screen.getByText(/#42/)).toBeInTheDocument();
+ });
+
+ it("shows authored MR badge", () => {
+ renderCard(makeFocusItem({ type: "mr_authored" }));
+ expect(screen.getByText(/MR AUTHORED/i)).toBeInTheDocument();
+ });
+ });
+
+ describe("action buttons", () => {
+ it("calls onStart when Start button is clicked", async () => {
+ const user = userEvent.setup();
+ renderCard();
+
+ await user.click(screen.getByRole("button", { name: /start/i }));
+ expect(onStart).toHaveBeenCalledOnce();
+ });
+
+ it("calls onDefer1h when 1 hour button is clicked", async () => {
+ const user = userEvent.setup();
+ renderCard();
+
+ await user.click(screen.getByRole("button", { name: /1 hour/i }));
+ expect(onDefer1h).toHaveBeenCalledOnce();
+ });
+
+ it("calls onDeferTomorrow when Tomorrow button is clicked", async () => {
+ const user = userEvent.setup();
+ renderCard();
+
+ await user.click(screen.getByRole("button", { name: /tomorrow/i }));
+ expect(onDeferTomorrow).toHaveBeenCalledOnce();
+ });
+
+ it("calls onSkip when Skip button is clicked", async () => {
+ const user = userEvent.setup();
+ renderCard();
+
+ await user.click(screen.getByRole("button", { name: /skip/i }));
+ expect(onSkip).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe("keyboard shortcuts", () => {
+ it("triggers Start on Enter key", async () => {
+ const user = userEvent.setup();
+ renderCard();
+
+ await user.keyboard("{Enter}");
+ expect(onStart).toHaveBeenCalledOnce();
+ });
+
+ it("triggers Skip on Cmd+S", async () => {
+ const user = userEvent.setup();
+ renderCard();
+
+ await user.keyboard("{Meta>}s{/Meta}");
+ expect(onSkip).toHaveBeenCalledOnce();
+ });
+ });
+});
+
+describe("FocusCard empty state", () => {
+ it("shows empty state message when no item", () => {
+ render(
+
+ );
+
+ expect(screen.getByText(/all clear/i)).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/QueueItem.test.tsx b/tests/components/QueueItem.test.tsx
new file mode 100644
index 0000000..28bc7c6
--- /dev/null
+++ b/tests/components/QueueItem.test.tsx
@@ -0,0 +1,100 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueueItem } from "@/components/QueueItem";
+import { makeFocusItem } from "../helpers/fixtures";
+
+describe("QueueItem", () => {
+ it("renders the item title", () => {
+ render();
+ expect(
+ screen.getByText("Fix authentication token refresh logic")
+ ).toBeInTheDocument();
+ });
+
+ it("renders the type badge", () => {
+ render();
+ expect(screen.getByText(/MR REVIEW/i)).toBeInTheDocument();
+ });
+
+ it("renders the IID with correct prefix for MRs", () => {
+ render();
+ expect(screen.getByText(/!847/)).toBeInTheDocument();
+ });
+
+ it("renders the IID with # prefix for issues", () => {
+ render(
+
+ );
+ expect(screen.getByText(/#42/)).toBeInTheDocument();
+ });
+
+ it("renders the project name", () => {
+ render();
+ expect(screen.getByText(/platform\/core/)).toBeInTheDocument();
+ });
+
+ it("calls onClick when clicked", async () => {
+ const onClick = vi.fn();
+ const user = userEvent.setup();
+ const item = makeFocusItem({ id: "test-click" });
+
+ render();
+ await user.click(screen.getByRole("button"));
+
+ expect(onClick).toHaveBeenCalledOnce();
+ expect(onClick).toHaveBeenCalledWith("test-click");
+ });
+
+ it("shows staleness color for fresh items", () => {
+ const freshItem = makeFocusItem({
+ updatedAt: new Date().toISOString(),
+ });
+ const { container } = render(
+
+ );
+ // Fresh items should have green indicator
+ expect(container.querySelector("[data-staleness='fresh']")).toBeTruthy();
+ });
+
+ it("shows staleness color for urgent items", () => {
+ const oldItem = makeFocusItem({
+ updatedAt: new Date(
+ Date.now() - 10 * 24 * 60 * 60 * 1000
+ ).toISOString(),
+ });
+ const { container } = render(
+
+ );
+ expect(container.querySelector("[data-staleness='urgent']")).toBeTruthy();
+ });
+
+ it("shows requestedBy when present", () => {
+ render(
+
+ );
+ expect(screen.getByText(/@alice/)).toBeInTheDocument();
+ });
+
+ it("staleness indicator has accessible label for fresh items", () => {
+ const freshItem = makeFocusItem({
+ updatedAt: new Date().toISOString(),
+ });
+ render();
+ expect(screen.getByRole("img", { name: /updated recently/i })).toBeInTheDocument();
+ });
+
+ it("staleness indicator has accessible label for urgent items", () => {
+ const oldItem = makeFocusItem({
+ updatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
+ });
+ render();
+ expect(screen.getByRole("img", { name: /needs attention/i })).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/QueueSummary.test.tsx b/tests/components/QueueSummary.test.tsx
new file mode 100644
index 0000000..304b260
--- /dev/null
+++ b/tests/components/QueueSummary.test.tsx
@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { QueueSummary } from "@/components/QueueSummary";
+import { makeFocusItem } from "../helpers/fixtures";
+
+describe("QueueSummary", () => {
+ it("shows empty queue message when no items", () => {
+ render();
+ expect(screen.getByText(/queue is empty/i)).toBeInTheDocument();
+ });
+
+ it("shows review count", () => {
+ const queue = [
+ makeFocusItem({ id: "a", type: "mr_review" }),
+ makeFocusItem({ id: "b", type: "mr_review" }),
+ ];
+ render();
+ expect(screen.getByText(/2 reviews/)).toBeInTheDocument();
+ });
+
+ it("shows singular for 1 item", () => {
+ const queue = [makeFocusItem({ id: "a", type: "issue" })];
+ render();
+ expect(screen.getByText(/1 issue(?!s)/)).toBeInTheDocument();
+ });
+
+ it("shows mixed counts separated by dots", () => {
+ const queue = [
+ makeFocusItem({ id: "a", type: "mr_review" }),
+ makeFocusItem({ id: "b", type: "issue" }),
+ makeFocusItem({ id: "c", type: "issue" }),
+ makeFocusItem({ id: "d", type: "manual" }),
+ ];
+ render();
+ const text = screen.getByText(/Queue:/);
+ expect(text.textContent).toContain("1 review");
+ expect(text.textContent).toContain("2 issues");
+ expect(text.textContent).toContain("1 task");
+ });
+});
diff --git a/tests/components/QueueView.test.tsx b/tests/components/QueueView.test.tsx
new file mode 100644
index 0000000..93c765f
--- /dev/null
+++ b/tests/components/QueueView.test.tsx
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueueView } from "@/components/QueueView";
+import { useFocusStore } from "@/stores/focus-store";
+import { makeFocusItem } from "../helpers/fixtures";
+
+describe("QueueView", () => {
+ beforeEach(() => {
+ useFocusStore.setState({
+ current: null,
+ queue: [],
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ it("shows empty state when no items", () => {
+ render();
+ expect(screen.getByText(/no items/i)).toBeInTheDocument();
+ });
+
+ it("groups items by type with section headers", () => {
+ useFocusStore.setState({
+ current: makeFocusItem({ id: "current" }),
+ queue: [
+ makeFocusItem({ id: "r1", type: "mr_review", title: "Review A" }),
+ makeFocusItem({ id: "r2", type: "mr_review", title: "Review B" }),
+ makeFocusItem({ id: "i1", type: "issue", title: "Issue A" }),
+ makeFocusItem({
+ id: "m1",
+ type: "mr_authored",
+ title: "My MR",
+ }),
+ ],
+ });
+
+ render();
+
+ expect(screen.getByText(/REVIEWS/i)).toBeInTheDocument();
+ expect(screen.getByText(/ISSUES/i)).toBeInTheDocument();
+ expect(screen.getByText(/AUTHORED MRS/i)).toBeInTheDocument();
+ });
+
+ it("shows item count in section headers", () => {
+ useFocusStore.setState({
+ current: makeFocusItem({ id: "current", type: "issue" }),
+ queue: [
+ makeFocusItem({ id: "r1", type: "mr_review" }),
+ makeFocusItem({ id: "r2", type: "mr_review" }),
+ makeFocusItem({ id: "i1", type: "issue" }),
+ ],
+ });
+
+ render();
+
+ // Text is split across elements, so use a function matcher
+ const reviewsHeader = screen.getByText((_content, element) => {
+ return element?.tagName === "H2" && element.textContent === "REVIEWS (2)";
+ });
+ expect(reviewsHeader).toBeInTheDocument();
+
+ const issuesHeader = screen.getByText((_content, element) => {
+ return element?.tagName === "H2" && element.textContent === "ISSUES (2)";
+ });
+ expect(issuesHeader).toBeInTheDocument();
+ });
+
+ it("includes current focus item in the list", () => {
+ useFocusStore.setState({
+ current: makeFocusItem({
+ id: "focused",
+ type: "mr_review",
+ title: "Focused item",
+ }),
+ queue: [
+ makeFocusItem({ id: "q1", type: "issue", title: "Queued item" }),
+ ],
+ });
+
+ render();
+
+ expect(screen.getByText("Focused item")).toBeInTheDocument();
+ expect(screen.getByText("Queued item")).toBeInTheDocument();
+ });
+
+ it("calls onSetFocus when an item is clicked", async () => {
+ const onSetFocus = vi.fn();
+ const user = userEvent.setup();
+
+ useFocusStore.setState({
+ current: makeFocusItem({ id: "current" }),
+ queue: [
+ makeFocusItem({ id: "target", type: "issue", title: "Click me" }),
+ ],
+ });
+
+ render();
+
+ await user.click(screen.getByText("Click me"));
+ expect(onSetFocus).toHaveBeenCalledWith("target");
+ });
+
+ it("marks the current focus item visually", () => {
+ useFocusStore.setState({
+ current: makeFocusItem({ id: "focused", title: "Current focus" }),
+ queue: [],
+ });
+
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector("[data-focused='true']")).toBeTruthy();
+ });
+});
diff --git a/tests/components/QuickCapture.test.tsx b/tests/components/QuickCapture.test.tsx
new file mode 100644
index 0000000..ad7546a
--- /dev/null
+++ b/tests/components/QuickCapture.test.tsx
@@ -0,0 +1,182 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QuickCapture } from "@/components/QuickCapture";
+import { useCaptureStore } from "@/stores/capture-store";
+import { invoke } from "@tauri-apps/api";
+
+describe("QuickCapture", () => {
+ beforeEach(() => {
+ useCaptureStore.setState({
+ isOpen: true,
+ isSubmitting: false,
+ lastCapturedId: null,
+ error: null,
+ });
+ vi.clearAllMocks();
+ });
+
+ describe("rendering", () => {
+ it("renders nothing when closed", () => {
+ useCaptureStore.setState({ isOpen: false });
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders overlay when open", () => {
+ render();
+ expect(screen.getByPlaceholderText("Capture a thought...")).toBeInTheDocument();
+ });
+
+ it("auto-focuses the input", () => {
+ render();
+ const input = screen.getByPlaceholderText("Capture a thought...");
+ expect(input).toHaveFocus();
+ });
+
+ it("shows a submit button", () => {
+ render();
+ expect(screen.getByRole("button", { name: /capture/i })).toBeInTheDocument();
+ });
+ });
+
+ describe("submission", () => {
+ it("calls quick_capture on Enter", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByPlaceholderText("Capture a thought...");
+ await user.type(input, "Fix the login bug{enter}");
+
+ await waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith("quick_capture", {
+ title: "Fix the login bug",
+ });
+ });
+ });
+
+ it("calls quick_capture on button click", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByPlaceholderText("Capture a thought...");
+ await user.type(input, "New feature idea");
+ await user.click(screen.getByRole("button", { name: /capture/i }));
+
+ await waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith("quick_capture", {
+ title: "New feature idea",
+ });
+ });
+ });
+
+ it("does not submit empty input", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByPlaceholderText("Capture a thought...");
+ await user.type(input, "{enter}");
+
+ expect(invoke).not.toHaveBeenCalledWith("quick_capture", expect.anything());
+ });
+
+ it("does not submit whitespace-only input", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByPlaceholderText("Capture a thought...");
+ await user.type(input, " {enter}");
+
+ expect(invoke).not.toHaveBeenCalledWith("quick_capture", expect.anything());
+ });
+
+ it("closes overlay on successful capture", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByPlaceholderText("Capture a thought...");
+ await user.type(input, "Quick thought{enter}");
+
+ await waitFor(() => {
+ expect(useCaptureStore.getState().isOpen).toBe(false);
+ });
+ });
+
+ it("shows error on failed capture", async () => {
+ (invoke as ReturnType).mockRejectedValueOnce({
+ code: "BEADS_UNAVAILABLE",
+ message: "br CLI not found",
+ recoverable: true,
+ });
+
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByPlaceholderText("Capture a thought...");
+ await user.type(input, "Doomed thought{enter}");
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ });
+ });
+
+ it("disables input during submission", async () => {
+ // Make invoke hang
+ (invoke as ReturnType).mockImplementationOnce(
+ () => new Promise(() => {})
+ );
+
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByPlaceholderText("Capture a thought...");
+ await user.type(input, "Slow thought{enter}");
+
+ await waitFor(() => {
+ expect(input).toBeDisabled();
+ });
+ });
+ });
+
+ describe("dismissal", () => {
+ it("closes on Escape", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.keyboard("{Escape}");
+
+ expect(useCaptureStore.getState().isOpen).toBe(false);
+ });
+
+ it("closes on backdrop click", async () => {
+ const user = userEvent.setup();
+ render();
+
+ // Click the backdrop (the outer overlay div)
+ const backdrop = screen.getByTestId("capture-backdrop");
+ await user.click(backdrop);
+
+ expect(useCaptureStore.getState().isOpen).toBe(false);
+ });
+ });
+
+ describe("error display", () => {
+ it("shows error message from store", () => {
+ useCaptureStore.setState({ error: "br CLI not found" });
+ render();
+
+ expect(screen.getByRole("alert")).toHaveTextContent("br CLI not found");
+ });
+
+ it("clears error when user types", async () => {
+ useCaptureStore.setState({ error: "previous error" });
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByPlaceholderText("Capture a thought...");
+ await user.type(input, "a");
+
+ expect(useCaptureStore.getState().error).toBeNull();
+ });
+ });
+});
diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts
index d657bfd..a2bd21c 100644
--- a/tests/e2e/app.spec.ts
+++ b/tests/e2e/app.spec.ts
@@ -1,25 +1,237 @@
-import { test, expect } from "@playwright/test";
+import { test, expect, type Page } from "@playwright/test";
-test.describe("Mission Control App", () => {
- test("displays the main heading", async ({ page }) => {
+/**
+ * Inject mock data into the Zustand focus store via the browser console.
+ *
+ * Since we run against the Vite dev server (no Tauri runtime),
+ * we seed stores directly to test the real React rendering pipeline.
+ */
+async function seedFocusStore(page: Page): Promise {
+ await page.evaluate(() => {
+ // Access Zustand stores via their internal getState/setState
+ // The stores are module-scoped singletons, accessible from window.__ZUSTAND_STORES__
+ // We expose them in development via a small shim in main.tsx
+ const w = window as Record;
+ const focusStore = w.__MC_FOCUS_STORE__ as {
+ setState: (state: Record) => void;
+ };
+ if (focusStore) {
+ focusStore.setState({
+ current: {
+ id: "mr_review:platform/core:847",
+ title: "Fix authentication token refresh logic",
+ type: "mr_review",
+ project: "platform/core",
+ url: "https://gitlab.com/platform/core/-/merge_requests/847",
+ iid: 847,
+ updatedAt: new Date().toISOString(),
+ contextQuote: "The refresh token logic has a race condition",
+ requestedBy: "johndoe",
+ },
+ queue: [
+ {
+ id: "issue:platform/core:42",
+ title: "Users unable to login after password reset",
+ type: "issue",
+ project: "platform/core",
+ url: "https://gitlab.com/platform/core/-/issues/42",
+ iid: 42,
+ updatedAt: new Date(
+ Date.now() - 5 * 24 * 60 * 60 * 1000
+ ).toISOString(),
+ contextQuote: null,
+ requestedBy: null,
+ },
+ {
+ id: "mr_review:platform/api:101",
+ title: "Add rate limiting to public endpoints",
+ type: "mr_review",
+ project: "platform/api",
+ url: "https://gitlab.com/platform/api/-/merge_requests/101",
+ iid: 101,
+ updatedAt: new Date(
+ Date.now() - 10 * 24 * 60 * 60 * 1000
+ ).toISOString(),
+ contextQuote: null,
+ requestedBy: "alice",
+ },
+ ],
+ isLoading: false,
+ error: null,
+ });
+ }
+ });
+}
+
+/**
+ * Expose Zustand stores on the window object for E2E test seeding.
+ * This is done by evaluating a script that patches the store modules.
+ */
+async function exposeStores(page: Page): Promise {
+ await page.evaluate(() => {
+ // The stores are ES module singletons. We need to wait for them
+ // to be available. In dev mode, Vite's HMR keeps them accessible.
+ // We use a polling approach to find them.
+ return new Promise((resolve) => {
+ const check = (): void => {
+ const w = window as Record;
+ if (w.__MC_FOCUS_STORE__) {
+ resolve();
+ } else {
+ setTimeout(check, 50);
+ }
+ };
+ check();
+ });
+ });
+}
+
+test.describe("Mission Control E2E", () => {
+ test.beforeEach(async ({ page }) => {
await page.goto("/");
-
- await expect(page.getByText("Mission Control")).toBeVisible();
+ // Wait for React to mount
+ await page.waitForSelector("nav");
});
- test("displays the tagline", async ({ page }) => {
- await page.goto("/");
+ test.describe("Focus View", () => {
+ test("shows empty state when no items", async ({ page }) => {
+ await expect(page.getByText("All Clear")).toBeVisible();
+ await expect(
+ page.getByText("Nothing needs your attention right now")
+ ).toBeVisible();
+ });
- await expect(
- page.getByText("What should you be doing right now?")
- ).toBeVisible();
+ test("shows navigation tabs", async ({ page }) => {
+ await expect(page.getByRole("button", { name: "Focus" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Queue" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Inbox" })).toBeVisible();
+ });
+
+ test("shows focus item when store has data", async ({ page }) => {
+ try {
+ await exposeStores(page);
+ await seedFocusStore(page);
+ } catch {
+ // Stores not exposed -- skip this test in environments without the shim
+ test.skip();
+ return;
+ }
+
+ await expect(
+ page.getByText("Fix authentication token refresh logic")
+ ).toBeVisible();
+ await expect(page.getByText("MR REVIEW")).toBeVisible();
+ await expect(page.getByText("!847 in platform/core")).toBeVisible();
+ });
+
+ test("shows action buttons when item is focused", async ({ page }) => {
+ try {
+ await exposeStores(page);
+ await seedFocusStore(page);
+ } catch {
+ test.skip();
+ return;
+ }
+
+ await expect(page.getByText("Start")).toBeVisible();
+ await expect(page.getByText("1 hour")).toBeVisible();
+ await expect(page.getByText("Tomorrow")).toBeVisible();
+ await expect(page.getByText("Skip")).toBeVisible();
+ });
});
- test("has dark mode styling", async ({ page }) => {
- await page.goto("/");
+ test.describe("Navigation", () => {
+ test("switches between Focus and Queue views", async ({ page }) => {
+ // Start in Focus view
+ await expect(page.getByText("All Clear")).toBeVisible();
- // Check that the page has dark background (zinc-900)
- const body = page.locator("body");
- await expect(body).toHaveClass(/bg-surface/);
+ // Click Queue tab
+ await page.getByRole("button", { name: "Queue" }).click();
+ await expect(page.getByText("No items in the queue")).toBeVisible();
+
+ // Click Focus tab
+ await page.getByRole("button", { name: "Focus" }).click();
+ await expect(page.getByText("All Clear")).toBeVisible();
+ });
+
+ test("shows Inbox placeholder", async ({ page }) => {
+ await page.getByRole("button", { name: "Inbox" }).click();
+ await expect(page.getByText("Inbox view coming in Phase 4b")).toBeVisible();
+ });
+
+ test("Queue tab shows item count badge when store has data", async ({
+ page,
+ }) => {
+ try {
+ await exposeStores(page);
+ await seedFocusStore(page);
+ } catch {
+ test.skip();
+ return;
+ }
+
+ // 1 current + 2 queue = 3
+ await expect(page.getByText("3")).toBeVisible();
+ });
+ });
+
+ test.describe("Queue View", () => {
+ test("shows items grouped by type when store has data", async ({
+ page,
+ }) => {
+ try {
+ await exposeStores(page);
+ await seedFocusStore(page);
+ } catch {
+ test.skip();
+ return;
+ }
+
+ await page.getByRole("button", { name: "Queue" }).click();
+
+ // Should have a Reviews section with 2 items
+ await expect(page.getByText("REVIEWS (2)")).toBeVisible();
+ // Should have an Issues section with 1 item
+ await expect(page.getByText("ISSUES (1)")).toBeVisible();
+ });
+
+ test("clicking item switches to Focus view", async ({ page }) => {
+ try {
+ await exposeStores(page);
+ await seedFocusStore(page);
+ } catch {
+ test.skip();
+ return;
+ }
+
+ await page.getByRole("button", { name: "Queue" }).click();
+
+ // Click the issue item
+ await page
+ .getByText("Users unable to login after password reset")
+ .click();
+
+ // Should switch to focus view -- wait for the Queue header to disappear
+ await expect(page.getByText("ISSUES (1)")).not.toBeVisible();
+
+ // The clicked item should now be THE ONE THING
+ await expect(
+ page.getByRole("heading", {
+ name: "Users unable to login after password reset",
+ })
+ ).toBeVisible();
+ });
+ });
+
+ test.describe("Dark mode", () => {
+ test("page has dark background", async ({ page }) => {
+ const body = page.locator("body");
+ await expect(body).toHaveClass(/bg-surface/);
+ });
+
+ test("HTML element has dark class", async ({ page }) => {
+ const html = page.locator("html");
+ await expect(html).toHaveClass(/dark/);
+ });
});
});
diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts
new file mode 100644
index 0000000..c7518bb
--- /dev/null
+++ b/tests/helpers/fixtures.ts
@@ -0,0 +1,25 @@
+/**
+ * Shared test fixtures for Mission Control frontend tests.
+ *
+ * Centralized here to avoid duplication across test files.
+ */
+
+import type { FocusItem } from "@/lib/types";
+
+/** Create a FocusItem with sensible defaults, overridable per field. */
+export function makeFocusItem(
+ overrides: Partial = {}
+): FocusItem {
+ return {
+ id: "mr_review:platform/core:847",
+ title: "Fix authentication token refresh logic",
+ type: "mr_review",
+ project: "platform/core",
+ url: "https://gitlab.com/platform/core/-/merge_requests/847",
+ iid: 847,
+ updatedAt: new Date().toISOString(),
+ contextQuote: null,
+ requestedBy: null,
+ ...overrides,
+ };
+}
diff --git a/tests/lib/format.test.ts b/tests/lib/format.test.ts
new file mode 100644
index 0000000..1391ee0
--- /dev/null
+++ b/tests/lib/format.test.ts
@@ -0,0 +1,20 @@
+import { describe, it, expect } from "vitest";
+import { formatIid } from "@/lib/format";
+
+describe("formatIid", () => {
+ it("formats mr_review with ! prefix", () => {
+ expect(formatIid("mr_review", 847)).toBe("!847");
+ });
+
+ it("formats mr_authored with ! prefix", () => {
+ expect(formatIid("mr_authored", 123)).toBe("!123");
+ });
+
+ it("formats issue with # prefix", () => {
+ expect(formatIid("issue", 42)).toBe("#42");
+ });
+
+ it("formats manual task with # prefix", () => {
+ expect(formatIid("manual", 1)).toBe("#1");
+ });
+});
diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts
new file mode 100644
index 0000000..8c7e663
--- /dev/null
+++ b/tests/lib/transform.test.ts
@@ -0,0 +1,119 @@
+import { describe, it, expect } from "vitest";
+import { transformLoreData } from "@/lib/transform";
+
+describe("transformLoreData", () => {
+ it("returns empty array for empty data", () => {
+ const result = transformLoreData({
+ open_issues: [],
+ open_mrs_authored: [],
+ reviewing_mrs: [],
+ });
+ expect(result).toEqual([]);
+ });
+
+ it("puts reviews first, then issues, then authored MRs", () => {
+ const result = transformLoreData({
+ open_issues: [
+ {
+ iid: 42,
+ title: "Bug fix",
+ project: "g/p",
+ web_url: "https://gitlab.com/g/p/-/issues/42",
+ },
+ ],
+ open_mrs_authored: [
+ {
+ iid: 200,
+ title: "My feature",
+ project: "g/p",
+ web_url: "https://gitlab.com/g/p/-/merge_requests/200",
+ },
+ ],
+ reviewing_mrs: [
+ {
+ iid: 100,
+ title: "Review this",
+ project: "g/p",
+ web_url: "https://gitlab.com/g/p/-/merge_requests/100",
+ author_username: "alice",
+ },
+ ],
+ });
+
+ expect(result).toHaveLength(3);
+ expect(result[0].type).toBe("mr_review");
+ expect(result[0].requestedBy).toBe("alice");
+ expect(result[1].type).toBe("issue");
+ expect(result[2].type).toBe("mr_authored");
+ });
+
+ it("generates correct IDs for each type", () => {
+ const result = transformLoreData({
+ open_issues: [
+ {
+ iid: 42,
+ title: "Issue",
+ project: "group/repo",
+ web_url: "https://x.com",
+ },
+ ],
+ open_mrs_authored: [
+ {
+ iid: 200,
+ title: "MR",
+ project: "group/repo",
+ web_url: "https://x.com",
+ },
+ ],
+ reviewing_mrs: [
+ {
+ iid: 100,
+ title: "Review",
+ project: "group/repo",
+ web_url: "https://x.com",
+ },
+ ],
+ });
+
+ expect(result[0].id).toBe("mr_review:group/repo:100");
+ expect(result[1].id).toBe("issue:group/repo:42");
+ expect(result[2].id).toBe("mr_authored:group/repo:200");
+ });
+
+ it("preserves updated_at_iso from lore data", () => {
+ const result = transformLoreData({
+ open_issues: [],
+ open_mrs_authored: [],
+ reviewing_mrs: [
+ {
+ iid: 1,
+ title: "T",
+ project: "g/p",
+ web_url: "https://x.com",
+ updated_at_iso: "2026-02-25T10:00:00Z",
+ },
+ ],
+ });
+
+ expect(result[0].updatedAt).toBe("2026-02-25T10:00:00Z");
+ });
+
+ it("handles missing optional fields gracefully", () => {
+ const result = transformLoreData({
+ open_issues: [
+ {
+ iid: 1,
+ title: "T",
+ project: "g/p",
+ web_url: "https://x.com",
+ },
+ ],
+ open_mrs_authored: [],
+ reviewing_mrs: [],
+ });
+
+ expect(result[0].updatedAt).toBeNull();
+ expect(result[0].contextQuote).toBeNull();
+ expect(result[0].requestedBy).toBeNull();
+ });
+});
diff --git a/tests/lib/types.test.ts b/tests/lib/types.test.ts
new file mode 100644
index 0000000..a4258d7
--- /dev/null
+++ b/tests/lib/types.test.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect } from "vitest";
+import { computeStaleness, isMcError } from "@/lib/types";
+
+describe("computeStaleness", () => {
+ it("returns 'normal' for null timestamp", () => {
+ expect(computeStaleness(null)).toBe("normal");
+ });
+
+ it("returns 'fresh' for items less than 1 day old", () => {
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
+ expect(computeStaleness(twoHoursAgo)).toBe("fresh");
+ });
+
+ it("returns 'normal' for items 1-2 days old", () => {
+ const thirtyHoursAgo = new Date(
+ Date.now() - 30 * 60 * 60 * 1000
+ ).toISOString();
+ expect(computeStaleness(thirtyHoursAgo)).toBe("normal");
+ });
+
+ it("returns 'amber' for items 3-6 days old", () => {
+ const fourDaysAgo = new Date(
+ Date.now() - 4 * 24 * 60 * 60 * 1000
+ ).toISOString();
+ expect(computeStaleness(fourDaysAgo)).toBe("amber");
+ });
+
+ it("returns 'urgent' for items 7+ days old", () => {
+ const tenDaysAgo = new Date(
+ Date.now() - 10 * 24 * 60 * 60 * 1000
+ ).toISOString();
+ expect(computeStaleness(tenDaysAgo)).toBe("urgent");
+ });
+});
+
+describe("isMcError", () => {
+ it("returns true for valid McError objects", () => {
+ const error = {
+ code: "LORE_UNAVAILABLE",
+ message: "lore CLI not found",
+ recoverable: true,
+ };
+ expect(isMcError(error)).toBe(true);
+ });
+
+ it("returns false for plain strings", () => {
+ expect(isMcError("some error")).toBe(false);
+ });
+
+ it("returns false for null", () => {
+ expect(isMcError(null)).toBe(false);
+ });
+
+ it("returns false for objects missing required fields", () => {
+ expect(isMcError({ code: "TEST" })).toBe(false);
+ expect(isMcError({ message: "test" })).toBe(false);
+ expect(isMcError({ recoverable: true })).toBe(false);
+ });
+});
diff --git a/tests/mocks/tauri-api.ts b/tests/mocks/tauri-api.ts
index adf5d35..e88c0a2 100644
--- a/tests/mocks/tauri-api.ts
+++ b/tests/mocks/tauri-api.ts
@@ -24,7 +24,21 @@ export const invoke = vi.fn(async (cmd: string, _args?: unknown) => {
last_sync: null,
is_healthy: true,
message: "Mock lore status",
+ summary: null,
};
+ case "get_bridge_status":
+ return {
+ mapping_count: 0,
+ pending_count: 0,
+ suspect_count: 0,
+ last_sync: null,
+ last_reconciliation: null,
+ };
+ case "sync_now":
+ case "reconcile":
+ return { created: 0, closed: 0, skipped: 0, healed: 0, errors: [] };
+ case "quick_capture":
+ return { bead_id: "bd-mock-capture" };
default:
throw new Error(`Mock not implemented for command: ${cmd}`);
}
@@ -39,16 +53,40 @@ export function setMockResponse(cmd: string, response: unknown): void {
export function resetMocks(): void {
invoke.mockClear();
Object.keys(mockResponses).forEach((key) => delete mockResponses[key]);
+ eventHandlers.clear();
+ listen.mockClear();
}
+// Event listener registry -- tests can trigger events via simulateEvent()
+type EventHandler = (payload: { payload: unknown }) => void;
+const eventHandlers: Map> = new Map();
+
// Mock event listener
export const listen = vi.fn(
- async (_event: string, _handler: (payload: unknown) => void) => {
+ async (event: string, handler: EventHandler) => {
+ if (!eventHandlers.has(event)) {
+ eventHandlers.set(event, new Set());
+ }
+ eventHandlers.get(event)!.add(handler);
+
// Return unlisten function
- return vi.fn();
+ const unlisten = vi.fn(() => {
+ eventHandlers.get(event)?.delete(handler);
+ });
+ return unlisten;
}
);
+/** Simulate a Tauri event being emitted (for test use). */
+export function simulateEvent(event: string, payload: unknown): void {
+ const handlers = eventHandlers.get(event);
+ if (handlers) {
+ for (const handler of handlers) {
+ handler({ payload });
+ }
+ }
+}
+
// Mock event emitter
export const emit = vi.fn(async (_event: string, _payload?: unknown) => {});
diff --git a/tests/mocks/tauri-plugin-shell.ts b/tests/mocks/tauri-plugin-shell.ts
new file mode 100644
index 0000000..a63fb7e
--- /dev/null
+++ b/tests/mocks/tauri-plugin-shell.ts
@@ -0,0 +1,7 @@
+/**
+ * Mock implementation of @tauri-apps/plugin-shell for testing.
+ */
+
+import { vi } from "vitest";
+
+export const open = vi.fn(async (_url: string) => {});
diff --git a/tests/stores/batch-store.test.ts b/tests/stores/batch-store.test.ts
new file mode 100644
index 0000000..3631f19
--- /dev/null
+++ b/tests/stores/batch-store.test.ts
@@ -0,0 +1,159 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { useBatchStore } from "@/stores/batch-store";
+import { makeFocusItem } from "../helpers/fixtures";
+
+describe("useBatchStore", () => {
+ beforeEach(() => {
+ useBatchStore.getState().exitBatch();
+ });
+
+ describe("startBatch", () => {
+ it("activates batch mode with items", () => {
+ const items = [
+ makeFocusItem({ id: "r1", title: "Review A" }),
+ makeFocusItem({ id: "r2", title: "Review B" }),
+ makeFocusItem({ id: "r3", title: "Review C" }),
+ ];
+
+ useBatchStore.getState().startBatch(items, "CODE REVIEWS");
+
+ const state = useBatchStore.getState();
+ expect(state.isActive).toBe(true);
+ expect(state.batchLabel).toBe("CODE REVIEWS");
+ expect(state.items).toHaveLength(3);
+ expect(state.statuses).toEqual(["pending", "pending", "pending"]);
+ expect(state.currentIndex).toBe(0);
+ expect(state.startedAt).toBeGreaterThan(0);
+ });
+
+ it("initializes with zero completed and skipped", () => {
+ useBatchStore.getState().startBatch(
+ [makeFocusItem({ id: "r1" })],
+ "TEST"
+ );
+
+ const state = useBatchStore.getState();
+ expect(state.completedCount()).toBe(0);
+ expect(state.skippedCount()).toBe(0);
+ expect(state.isFinished()).toBe(false);
+ });
+ });
+
+ describe("markDone", () => {
+ it("marks current item as done and advances", () => {
+ useBatchStore.getState().startBatch(
+ [
+ makeFocusItem({ id: "r1" }),
+ makeFocusItem({ id: "r2" }),
+ makeFocusItem({ id: "r3" }),
+ ],
+ "TEST"
+ );
+
+ useBatchStore.getState().markDone();
+
+ const state = useBatchStore.getState();
+ expect(state.statuses[0]).toBe("done");
+ expect(state.currentIndex).toBe(1);
+ expect(state.completedCount()).toBe(1);
+ });
+
+ it("finishes when last item is marked done", () => {
+ useBatchStore.getState().startBatch(
+ [makeFocusItem({ id: "r1" }), makeFocusItem({ id: "r2" })],
+ "TEST"
+ );
+
+ useBatchStore.getState().markDone();
+ useBatchStore.getState().markDone();
+
+ const state = useBatchStore.getState();
+ expect(state.completedCount()).toBe(2);
+ expect(state.isFinished()).toBe(true);
+ });
+ });
+
+ describe("markSkipped", () => {
+ it("marks current item as skipped and advances", () => {
+ useBatchStore.getState().startBatch(
+ [makeFocusItem({ id: "r1" }), makeFocusItem({ id: "r2" })],
+ "TEST"
+ );
+
+ useBatchStore.getState().markSkipped();
+
+ const state = useBatchStore.getState();
+ expect(state.statuses[0]).toBe("skipped");
+ expect(state.currentIndex).toBe(1);
+ expect(state.skippedCount()).toBe(1);
+ });
+
+ it("mixed done and skipped tracks correctly", () => {
+ useBatchStore.getState().startBatch(
+ [
+ makeFocusItem({ id: "r1" }),
+ makeFocusItem({ id: "r2" }),
+ makeFocusItem({ id: "r3" }),
+ ],
+ "TEST"
+ );
+
+ useBatchStore.getState().markDone();
+ useBatchStore.getState().markSkipped();
+ useBatchStore.getState().markDone();
+
+ const state = useBatchStore.getState();
+ expect(state.completedCount()).toBe(2);
+ expect(state.skippedCount()).toBe(1);
+ expect(state.isFinished()).toBe(true);
+ });
+ });
+
+ describe("exitBatch", () => {
+ it("clears all batch state", () => {
+ useBatchStore.getState().startBatch(
+ [makeFocusItem({ id: "r1" })],
+ "TEST"
+ );
+ useBatchStore.getState().markDone();
+
+ useBatchStore.getState().exitBatch();
+
+ const state = useBatchStore.getState();
+ expect(state.isActive).toBe(false);
+ expect(state.items).toHaveLength(0);
+ expect(state.statuses).toHaveLength(0);
+ expect(state.currentIndex).toBe(0);
+ expect(state.startedAt).toBeNull();
+ });
+ });
+
+ describe("progress tracking", () => {
+ it("reports correct progress through batch", () => {
+ useBatchStore.getState().startBatch(
+ [
+ makeFocusItem({ id: "r1" }),
+ makeFocusItem({ id: "r2" }),
+ makeFocusItem({ id: "r3" }),
+ makeFocusItem({ id: "r4" }),
+ ],
+ "REVIEWS"
+ );
+
+ expect(useBatchStore.getState().isFinished()).toBe(false);
+
+ useBatchStore.getState().markDone();
+ useBatchStore.getState().markDone();
+
+ expect(useBatchStore.getState().completedCount()).toBe(2);
+ expect(useBatchStore.getState().isFinished()).toBe(false);
+
+ useBatchStore.getState().markSkipped();
+ useBatchStore.getState().markDone();
+
+ expect(useBatchStore.getState().completedCount()).toBe(3);
+ expect(useBatchStore.getState().skippedCount()).toBe(1);
+ expect(useBatchStore.getState().isFinished()).toBe(true);
+ });
+ });
+});
diff --git a/tests/stores/capture-store.test.ts b/tests/stores/capture-store.test.ts
new file mode 100644
index 0000000..ce3d9b0
--- /dev/null
+++ b/tests/stores/capture-store.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { useCaptureStore } from "@/stores/capture-store";
+
+describe("useCaptureStore", () => {
+ beforeEach(() => {
+ useCaptureStore.setState({
+ isOpen: false,
+ isSubmitting: false,
+ lastCapturedId: null,
+ error: null,
+ });
+ });
+
+ it("starts closed", () => {
+ const state = useCaptureStore.getState();
+ expect(state.isOpen).toBe(false);
+ expect(state.isSubmitting).toBe(false);
+ });
+
+ it("opens the overlay", () => {
+ useCaptureStore.getState().open();
+ expect(useCaptureStore.getState().isOpen).toBe(true);
+ });
+
+ it("closes the overlay and resets state", () => {
+ useCaptureStore.setState({ isOpen: true, isSubmitting: true, error: "test" });
+ useCaptureStore.getState().close();
+
+ const state = useCaptureStore.getState();
+ expect(state.isOpen).toBe(false);
+ expect(state.isSubmitting).toBe(false);
+ expect(state.error).toBeNull();
+ });
+
+ it("sets submitting state", () => {
+ useCaptureStore.getState().setSubmitting(true);
+ expect(useCaptureStore.getState().isSubmitting).toBe(true);
+ });
+
+ it("records a successful capture", () => {
+ useCaptureStore.getState().captureSuccess("bd-123");
+
+ const state = useCaptureStore.getState();
+ expect(state.lastCapturedId).toBe("bd-123");
+ expect(state.isSubmitting).toBe(false);
+ expect(state.isOpen).toBe(false);
+ expect(state.error).toBeNull();
+ });
+
+ it("records a failed capture", () => {
+ useCaptureStore.setState({ isSubmitting: true });
+ useCaptureStore.getState().captureError("br command failed");
+
+ const state = useCaptureStore.getState();
+ expect(state.error).toBe("br command failed");
+ expect(state.isSubmitting).toBe(false);
+ expect(state.isOpen).toBe(true); // stays open so user can retry
+ });
+
+ it("clears error when opening", () => {
+ useCaptureStore.setState({ error: "old error" });
+ useCaptureStore.getState().open();
+
+ expect(useCaptureStore.getState().error).toBeNull();
+ });
+});
diff --git a/tests/stores/focus-store.test.ts b/tests/stores/focus-store.test.ts
new file mode 100644
index 0000000..9609f09
--- /dev/null
+++ b/tests/stores/focus-store.test.ts
@@ -0,0 +1,192 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { useFocusStore } from "@/stores/focus-store";
+import { makeFocusItem } from "../helpers/fixtures";
+
+describe("useFocusStore", () => {
+ beforeEach(() => {
+ // Reset store between tests
+ useFocusStore.setState({
+ current: null,
+ queue: [],
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ describe("setItems", () => {
+ it("sets first item as current and rest as queue", () => {
+ const items = [
+ makeFocusItem({ id: "a", title: "First" }),
+ makeFocusItem({ id: "b", title: "Second" }),
+ makeFocusItem({ id: "c", title: "Third" }),
+ ];
+
+ useFocusStore.getState().setItems(items);
+
+ const state = useFocusStore.getState();
+ expect(state.current?.id).toBe("a");
+ expect(state.queue).toHaveLength(2);
+ expect(state.queue[0].id).toBe("b");
+ expect(state.queue[1].id).toBe("c");
+ });
+
+ it("sets current to null when empty", () => {
+ useFocusStore.getState().setItems([]);
+
+ const state = useFocusStore.getState();
+ expect(state.current).toBeNull();
+ expect(state.queue).toHaveLength(0);
+ });
+
+ it("clears loading and error on setItems", () => {
+ useFocusStore.setState({ isLoading: true, error: "old error" });
+
+ useFocusStore.getState().setItems([makeFocusItem()]);
+
+ const state = useFocusStore.getState();
+ expect(state.isLoading).toBe(false);
+ expect(state.error).toBeNull();
+ });
+ });
+
+ describe("act", () => {
+ it("advances to next item in queue", () => {
+ useFocusStore.getState().setItems([
+ makeFocusItem({ id: "a" }),
+ makeFocusItem({ id: "b" }),
+ makeFocusItem({ id: "c" }),
+ ]);
+
+ const next = useFocusStore.getState().act("start");
+
+ expect(next?.id).toBe("b");
+ expect(useFocusStore.getState().current?.id).toBe("b");
+ expect(useFocusStore.getState().queue).toHaveLength(1);
+ });
+
+ it("returns null when queue is empty", () => {
+ useFocusStore.getState().setItems([makeFocusItem({ id: "only" })]);
+
+ const next = useFocusStore.getState().act("skip");
+
+ expect(next).toBeNull();
+ expect(useFocusStore.getState().current).toBeNull();
+ expect(useFocusStore.getState().queue).toHaveLength(0);
+ });
+
+ it("works with defer_1h action", () => {
+ useFocusStore.getState().setItems([
+ makeFocusItem({ id: "a" }),
+ makeFocusItem({ id: "b" }),
+ ]);
+
+ useFocusStore.getState().act("defer_1h", "in a meeting");
+
+ expect(useFocusStore.getState().current?.id).toBe("b");
+ });
+
+ it("works with defer_tomorrow action", () => {
+ useFocusStore.getState().setItems([
+ makeFocusItem({ id: "a" }),
+ makeFocusItem({ id: "b" }),
+ ]);
+
+ useFocusStore.getState().act("defer_tomorrow");
+
+ expect(useFocusStore.getState().current?.id).toBe("b");
+ });
+ });
+
+ describe("setFocus", () => {
+ it("promotes a queue item to current", () => {
+ useFocusStore.getState().setItems([
+ makeFocusItem({ id: "a", title: "First" }),
+ makeFocusItem({ id: "b", title: "Second" }),
+ makeFocusItem({ id: "c", title: "Third" }),
+ ]);
+
+ useFocusStore.getState().setFocus("c");
+
+ const state = useFocusStore.getState();
+ expect(state.current?.id).toBe("c");
+ // Previous current and other queue items are in queue
+ expect(state.queue.map((i) => i.id)).toEqual(
+ expect.arrayContaining(["a", "b"])
+ );
+ expect(state.queue).toHaveLength(2);
+ });
+
+ it("does nothing for unknown item ID", () => {
+ useFocusStore.getState().setItems([makeFocusItem({ id: "a" })]);
+
+ useFocusStore.getState().setFocus("nonexistent");
+
+ expect(useFocusStore.getState().current?.id).toBe("a");
+ });
+ });
+
+ describe("reorderQueue", () => {
+ it("moves an item from one position to another", () => {
+ useFocusStore.getState().setItems([
+ makeFocusItem({ id: "focus" }),
+ makeFocusItem({ id: "a" }),
+ makeFocusItem({ id: "b" }),
+ makeFocusItem({ id: "c" }),
+ ]);
+
+ useFocusStore.getState().reorderQueue(2, 0);
+
+ const ids = useFocusStore.getState().queue.map((i) => i.id);
+ expect(ids).toEqual(["c", "a", "b"]);
+ });
+
+ it("does nothing for same from/to index", () => {
+ useFocusStore.getState().setItems([
+ makeFocusItem({ id: "focus" }),
+ makeFocusItem({ id: "a" }),
+ makeFocusItem({ id: "b" }),
+ ]);
+
+ useFocusStore.getState().reorderQueue(0, 0);
+
+ const ids = useFocusStore.getState().queue.map((i) => i.id);
+ expect(ids).toEqual(["a", "b"]);
+ });
+
+ it("does nothing for out-of-bounds indices", () => {
+ useFocusStore.getState().setItems([
+ makeFocusItem({ id: "focus" }),
+ makeFocusItem({ id: "a" }),
+ ]);
+
+ useFocusStore.getState().reorderQueue(-1, 0);
+ useFocusStore.getState().reorderQueue(0, 5);
+
+ expect(useFocusStore.getState().queue.map((i) => i.id)).toEqual(["a"]);
+ });
+
+ it("does not affect current focus", () => {
+ useFocusStore.getState().setItems([
+ makeFocusItem({ id: "focus" }),
+ makeFocusItem({ id: "a" }),
+ makeFocusItem({ id: "b" }),
+ ]);
+
+ useFocusStore.getState().reorderQueue(1, 0);
+
+ expect(useFocusStore.getState().current?.id).toBe("focus");
+ });
+ });
+
+ describe("setLoading / setError", () => {
+ it("sets loading state", () => {
+ useFocusStore.getState().setLoading(true);
+ expect(useFocusStore.getState().isLoading).toBe(true);
+ });
+
+ it("sets error state", () => {
+ useFocusStore.getState().setError("something broke");
+ expect(useFocusStore.getState().error).toBe("something broke");
+ });
+ });
+});
diff --git a/tests/stores/nav-store.test.ts b/tests/stores/nav-store.test.ts
new file mode 100644
index 0000000..26fd928
--- /dev/null
+++ b/tests/stores/nav-store.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { useNavStore } from "@/stores/nav-store";
+
+describe("useNavStore", () => {
+ beforeEach(() => {
+ useNavStore.setState({ activeView: "focus" });
+ });
+
+ it("defaults to focus view", () => {
+ expect(useNavStore.getState().activeView).toBe("focus");
+ });
+
+ it("switches to queue view", () => {
+ useNavStore.getState().setView("queue");
+ expect(useNavStore.getState().activeView).toBe("queue");
+ });
+
+ it("switches to inbox view", () => {
+ useNavStore.getState().setView("inbox");
+ expect(useNavStore.getState().activeView).toBe("inbox");
+ });
+
+ it("switches back to focus", () => {
+ useNavStore.getState().setView("queue");
+ useNavStore.getState().setView("focus");
+ expect(useNavStore.getState().activeView).toBe("focus");
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 59ce37d..71046ff 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -17,9 +17,15 @@ export default defineConfig({
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/main.tsx", "src/**/*.d.ts"],
},
- // Mock Tauri APIs
+ // Mock Tauri APIs (including subpath exports)
alias: {
+ "@tauri-apps/api/core": path.resolve(__dirname, "./tests/mocks/tauri-api.ts"),
+ "@tauri-apps/api/event": path.resolve(__dirname, "./tests/mocks/tauri-api.ts"),
"@tauri-apps/api": path.resolve(__dirname, "./tests/mocks/tauri-api.ts"),
+ "@tauri-apps/plugin-shell": path.resolve(
+ __dirname,
+ "./tests/mocks/tauri-plugin-shell.ts"
+ ),
},
},
resolve: {