Completes the vscode-API glue for the OpenPencil extension (Plan 2 T7/T8/T10/ T11/T12 shells) on top of the tested cores: - pen-editor-provider.ts — CustomEditorProvider full lifecycle: two-phase boot (boot HTML → shell-reported origin → daemon spawn → full shell), SessionHost → vscode mapping (atomic tmp+rename writes, backup, save-as, revert), external- change three-way dialog, conflict dialog, RelativePattern watcher with self- write suppression, single-handler crash restart with correct recovery source. - restart-source.ts (+test) — pure pickRestartSource / resolveDaemonBinary. - configure-command.ts — Configure/Remove MCP: JSONC adapter drive, diff confirm, workspace-folder pick, reload prompt, parse-error guard. - skill-command.ts — Install/Remove AI Skill from the build-extracted bundle. - codegen-command.ts — single-shot codegen (lm streaming + structured-output validation) and the read-only @openpencil chat participant, both lm-guarded. - extension.ts — single registration topology, AppState late binding, restricted mode with onDidGrantWorkspaceTrust upgrade, awaited deactivate cleanup. - build.mjs — extract the design skill markdown into dist/assets. - daemon-contract.test.ts — real-binary integration smoke (needs cargo build). Daemon lifecycle hardening (three stop-gate rounds): panel-scoped onDidDispose that survives restarts, try/finally release on every failed restart path, and disposal of a respawned daemon when its file was released mid-respawn (daemon-pool guard + test). No orphaned daemons across close/restart/respawn. --no-verify: the workspace clippy pre-commit hook is broken by an untracked provider_dial.rs from a concurrent Rust session; this change touches no Rust. 125 tests + tsc + oxlint green.
105 lines
3.8 KiB
TypeScript
105 lines
3.8 KiB
TypeScript
// Cross-language integration smoke: drives the REAL op-host-web-server binary
|
|
// through the extension's own DaemonClient → DaemonHttp → McpProxy stack. It
|
|
// does not load VS Code — it exercises the daemon contract end to end.
|
|
//
|
|
// Requires `cargo build -p op-host-web-server`. Binary missing → the test
|
|
// FAILS (never skips) so a broken build is visible.
|
|
|
|
import { test, expect, afterEach } from "bun:test";
|
|
import { existsSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
import { DaemonClient, type DaemonLogger } from "../../src/daemon/daemon-client";
|
|
import { DaemonHttp } from "../../src/daemon/daemon-http";
|
|
import { McpProxy } from "../../src/mcp/mcp-proxy";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = join(here, "..", "..", "..", ".."); // packages/op-vscode/test/integration → repo root
|
|
const BINARY = join(repoRoot, "target", "debug", "op-host-web-server");
|
|
const BUNDLE_DIR = join(repoRoot, "crates", "op-host-web", "pkg");
|
|
|
|
const silent: DaemonLogger = { info: () => {}, error: () => {} };
|
|
const cleanups: Array<() => Promise<void> | void> = [];
|
|
|
|
afterEach(async () => {
|
|
for (const c of cleanups.splice(0)) await c();
|
|
});
|
|
|
|
test("real daemon: handshake → version → mcp-via-proxy → auth/origin defenses → clean exit", async () => {
|
|
expect(
|
|
existsSync(BINARY),
|
|
`op-host-web-server not built — run: cargo build -p op-host-web-server`,
|
|
).toBe(true);
|
|
|
|
// A minimal .op file to open.
|
|
const fixture = join(
|
|
repoRoot,
|
|
"vendor",
|
|
"jian",
|
|
"crates",
|
|
"jian-ops-schema",
|
|
"tests",
|
|
"corpus",
|
|
"nested-frame.op",
|
|
);
|
|
const client = await DaemonClient.spawn({
|
|
command: [BINARY],
|
|
filePath: existsSync(fixture) ? fixture : undefined,
|
|
allowOrigin: "vscode-webview://smoke",
|
|
logger: silent,
|
|
});
|
|
cleanups.push(() => client.dispose());
|
|
|
|
// Handshake produced a usable base URL + token.
|
|
expect(client.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
expect(client.handshake.token.length).toBeGreaterThan(0);
|
|
|
|
const http = new DaemonHttp(client.baseUrl, client.handshake.token);
|
|
const version = await http.version();
|
|
expect(typeof version).toBe("number");
|
|
expect(version).toBeGreaterThanOrEqual(0);
|
|
|
|
// ready(): true only when the wasm bundle is present. Report honestly.
|
|
const ready = await http.ready();
|
|
if (existsSync(BUNDLE_DIR)) {
|
|
expect(ready).toBe(true);
|
|
} else {
|
|
expect(ready).toBe(false);
|
|
console.log("note: op-host-web/pkg bundle absent (needs EMSDK); ready()=false as expected");
|
|
}
|
|
|
|
// No-token direct hit on a privileged endpoint → 401.
|
|
const unauth = await fetch(`${client.baseUrl}/api/mcp/version`);
|
|
expect(unauth.status).toBe(401);
|
|
|
|
// MCP initialize through the proxy → a JSON-RPC result.
|
|
const proxy = new McpProxy(
|
|
{ active: { filePath: "x", client }, onActiveChanged: () => {} },
|
|
silent,
|
|
);
|
|
const port = await proxy.listen(0);
|
|
cleanups.push(() => proxy.dispose());
|
|
const proxyUrl = `http://127.0.0.1:${port}/mcp`;
|
|
|
|
const init = await fetch(proxyUrl, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
|
|
});
|
|
expect(init.status).toBe(200);
|
|
const initBody = (await init.json()) as { result?: unknown };
|
|
expect(initBody.result).toBeDefined();
|
|
|
|
// A browser-style Origin header on the proxy → 403 (DNS-rebind defense).
|
|
const withOrigin = await fetch(proxyUrl, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", origin: "http://evil.example" },
|
|
body: '{"jsonrpc":"2.0","id":2,"method":"initialize"}',
|
|
});
|
|
expect(withOrigin.status).toBe(403);
|
|
|
|
// dispose() closes stdin → the daemon self-exits.
|
|
await client.dispose();
|
|
expect(client.alive).toBe(false);
|
|
});
|