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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/.vscode-test/
/.nyc_output/
/coverage/
/.claude/
*.vsix
flake.lock
pnpm-debug.log
Expand Down
27 changes: 21 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,30 @@ Comments explain what code does or why it exists:
## Build and Test Commands

- Build: `pnpm build`
- Watch mode: `pnpm watch`
- Watch mode: `pnpm watch` (or `pnpm watch:all`)
- Package: `pnpm package`
- Format: `pnpm fmt`
- Format check: `pnpm fmt:check`
- Lint: `pnpm lint`
- Lint with auto-fix: `pnpm lint:fix`
- Run all tests: `pnpm test`
- Unit tests: `pnpm test:ci`
- All unit tests: `pnpm test` (or `pnpm test:all`)
- Extension tests: `pnpm test:extension`
- Webview tests: `pnpm test:webview`
- CI mode: `pnpm test:ci`
- Integration tests: `pnpm test:integration`
- Run specific unit test: `pnpm test:ci ./test/unit/filename.test.ts`
- Run specific integration test: `pnpm test:integration ./test/integration/filename.test.ts`
- Run specific extension test: `pnpm test:extension ./test/unit/filename.test.ts`
- Run specific webview test: `pnpm test:webview ./test/webview/filename.test.ts`

## Test File Organization

```text
test/
├── unit/ # Extension unit tests (mirrors src/ structure)
├── webview/ # Webview unit tests (by package name)
├── integration/ # VS Code integration tests (uses Mocha, not Vitest)
├── utils/ # Test utilities that are also tested
└── mocks/ # Shared test mocks
```

## Code Style

Expand All @@ -69,5 +82,7 @@ Comments explain what code does or why it exists:
- Prefix unused variables with underscore (e.g., `_unused`)
- Error handling: wrap and type errors appropriately
- Use async/await for promises, avoid explicit Promise construction where possible
- Unit test files must be named `*.test.ts` and use Vitest, they should be placed in `./test/unit/<path in src>`
- Unit test files must be named `*.test.ts` and use Vitest
- Extension tests go in `./test/unit/<path in src>`
- Webview tests go in `./test/webview/<package name>/`
- Never disable ESLint rules without user approval
30 changes: 27 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,42 @@ The link format is `vscode://coder.coder-remote/open?${query}`. For example:
code --open-url 'vscode://coder.coder-remote/open?url=dev.coder.com&owner=my-username&workspace=my-ws&agent=my-agent'
```

There are unit tests using `vitest` with mocked VS Code APIs:
### Unit Tests

The project uses Vitest with separate test configurations for extension and webview code:

```bash
pnpm test:ci
pnpm test:extension # Extension tests (runs in Electron with mocked VS Code APIs)
pnpm test:webview # Webview tests (runs in jsdom)
pnpm test:all # Both extension and webview tests
pnpm test:ci # CI mode (same as test:all with CI=true)
```

Test files are organized by type:

```text
test/
├── unit/ # Extension unit tests
├── webview/ # Webview unit tests (jsdom environment)
├── integration/ # Integration tests (real VS Code)
└── mocks/ # Shared test mocks
```

There are also integration tests that run inside a real VS Code instance:
### Integration Tests

Integration tests run inside a real VS Code instance:

```bash
pnpm test:integration
```

**Limitations:**

- Must use Mocha (VS Code test runner requirement), not Vitest
- Cannot run while another VS Code instance is open (they share state)
- Requires closing VS Code or running in a clean environment
- Test files in `test/integration/` are compiled to `out/` before running

## Development

> [!IMPORTANT]
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export default defineConfig(

// Test files - use test tsconfig and relax some rules
{
files: ["test/**/*.ts", "**/*.test.ts", "**/*.spec.ts"],
files: ["test/**/*.{ts,tsx}", "**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
settings: {
"import-x/resolver-next": [
createTypeScriptImportResolver({ project: "test/tsconfig.json" }),
Expand Down
20 changes: 14 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
"lint:fix": "pnpm lint --fix",
"package": "vsce package --no-dependencies",
"package:prerelease": "vsce package --pre-release --no-dependencies",
"pretest": "tsc -p test --noEmit && pnpm fmt:check && pnpm lint",
"test": "ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs",
"test:ci": "CI=true pnpm test",
"test": "CI=true pnpm test:extension && CI=true pnpm test:webview",
"test:ci": "pnpm test",
"test:extension": "ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension",
"test:integration": "tsc -p test --outDir out && node esbuild.mjs && vscode-test",
"test:webview": "vitest --project webview",
"vscode:prepublish": "pnpm build:production",
"watch": "pnpm watch:all",
"watch:all": "concurrently -n extension,webviews \"pnpm watch:extension\" \"pnpm watch:webviews\"",
"watch:extension": "node esbuild.mjs --watch",
"watch:webviews": "pnpm -r --filter \"./packages/*\" --parallel dev"
Expand Down Expand Up @@ -463,17 +465,20 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"@eslint/markdown": "^7.5.1",
"@testing-library/react": "^16.3.2",
"@tsconfig/node20": "^20.1.8",
"@types/mocha": "^10.0.10",
"@types/node": "^20",
"@types/proper-lockfile": "^4.1.4",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/semver": "^7.7.1",
"@types/ua-parser-js": "0.7.39",
"@types/vscode": "^1.95.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitejs/plugin-react-swc": "^3.8.0",
"@vitejs/plugin-react-swc": "catalog:",
"@vitest/coverage-v8": "^4.0.16",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
Expand All @@ -492,13 +497,16 @@
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^5.0.0",
"globals": "^17.0.0",
"jsdom": "^27.4.0",
"jsonc-eslint-parser": "^2.4.2",
"memfs": "^4.56.10",
"prettier": "^3.7.4",
"typescript": "^5.9.3",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "^8.53.1",
"utf-8-validate": "^6.0.6",
"vite": "^6.0.0",
"vite": "catalog:",
"vitest": "^4.0.16"
},
"extensionPack": [
Expand Down
16 changes: 8 additions & 8 deletions packages/tasks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
},
"dependencies": {
"@repo/webview-shared": "workspace:*",
"@vscode-elements/react-elements": "^2.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"@vscode-elements/react-elements": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"typescript": "^5.7.3",
"vite": "^6.0.0"
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react-swc": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
}
}
24 changes: 13 additions & 11 deletions packages/tasks/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import { postMessage, useMessage } from "@repo/webview-shared/react";
import { logger } from "@repo/webview-shared/logger";
import { useMessage } from "@repo/webview-shared/react";
import {
VscodeButton,
VscodeProgressRing,
} from "@vscode-elements/react-elements";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";

import type { WebviewMessage } from "@repo/webview-shared";
import { sendReady, sendRefresh } from "./messages";

import type { TasksExtensionMessage } from "@repo/webview-shared";

export default function App() {
const [ready, setReady] = useState(false);

const handleMessage = useCallback((message: WebviewMessage) => {
useMessage<TasksExtensionMessage>((message) => {
switch (message.type) {
case "init":
setReady(true);
break;
case "error":
logger.error("Tasks panel error:", message.data);
break;
}
}, []);

useMessage(handleMessage);
});

useEffect(() => {
postMessage({ type: "ready" });
sendReady();
}, []);

if (!ready) {
Expand All @@ -31,9 +35,7 @@ export default function App() {
return (
<div>
<h2>Coder Tasks</h2>
<VscodeButton onClick={() => postMessage({ type: "refresh" })}>
Refresh
</VscodeButton>
<VscodeButton onClick={sendRefresh}>Refresh</VscodeButton>
</div>
);
}
17 changes: 12 additions & 5 deletions packages/tasks/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { ErrorBoundary } from "@repo/webview-shared/react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import App from "./App";
import "./index.css";

const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
if (!root) {
throw new Error(
"Failed to find root element. The webview HTML must contain an element with id='root'.",
);
}

createRoot(root).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);
17 changes: 17 additions & 0 deletions packages/tasks/src/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { postMessage } from "@repo/webview-shared/api";

import type { TasksWebviewMessage } from "@repo/webview-shared";

function sendMessage(message: TasksWebviewMessage): void {
postMessage(message);
}

/** Signal to the extension that the webview is ready */
export function sendReady(): void {
sendMessage({ type: "ready" });
}

/** Request task refresh from the extension */
export function sendRefresh(): void {
sendMessage({ type: "refresh" });
}
6 changes: 5 additions & 1 deletion packages/webview-shared/extension.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
// Types exposed to the extension (react/ subpath is excluded).
export type { WebviewMessage } from "./src/index";
export type {
TasksExtensionMessage,
TasksWebviewMessage,
WebviewMessage,
} from "./src/index";
21 changes: 17 additions & 4 deletions packages/webview-shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,36 @@
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./api": {
"types": "./src/api.ts",
"default": "./src/api.ts"
},
"./logger": {
"types": "./src/logger.ts",
"default": "./src/logger.ts"
},
"./react": {
"types": "./src/react/index.ts",
"default": "./src/react/index.ts"
}
},
"peerDependencies": {
"react": "^19.0.0"
"@vscode-elements/react-elements": "catalog:",
"react": "catalog:"
},
"peerDependenciesMeta": {
"@vscode-elements/react-elements": {
"optional": true
},
"react": {
"optional": true
}
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react": "catalog:",
"@types/vscode-webview": "^1.57.5",
"react": "^19.0.0",
"typescript": "^5.7.3"
"@vscode-elements/react-elements": "catalog:",
"react": "catalog:",
"typescript": "catalog:"
}
}
8 changes: 8 additions & 0 deletions packages/webview-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@ export interface WebviewMessage<T = unknown> {
type: string;
data?: T;
}

/** Messages sent from the extension to the Tasks webview */
export type TasksExtensionMessage =
| { type: "init" }
| { type: "error"; data: string };

/** Messages sent from the Tasks webview to the extension */
export type TasksWebviewMessage = { type: "ready" } | { type: "refresh" };
40 changes: 40 additions & 0 deletions packages/webview-shared/src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Logger interface that can be customized to send logs elsewhere (e.g., to the extension).
*/
export interface Logger {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}

/**
* Default logger that writes to the browser console.
* In webviews, this appears in the DevTools console.
*/
const consoleLogger: Logger = {
/* eslint-disable no-console */
debug: (...args) => console.debug("[webview]", ...args),
info: (...args) => console.info("[webview]", ...args),
warn: (...args) => console.warn("[webview]", ...args),
error: (...args) => console.error("[webview]", ...args),
/* eslint-enable no-console */
};

let currentLogger: Logger = consoleLogger;

/**
* Set a custom logger implementation.
* Call this early in your webview's initialization to redirect logs.
*/
export function setLogger(logger: Logger): void {
currentLogger = logger;
}

// Convenience exports for direct use
export const logger = {
debug: (...args: unknown[]) => currentLogger.debug(...args),
info: (...args: unknown[]) => currentLogger.info(...args),
warn: (...args: unknown[]) => currentLogger.warn(...args),
error: (...args: unknown[]) => currentLogger.error(...args),
};
Loading