Files
openidea/src/features/run/contextRun.test.ts
T
l0ng-ai 2c9a6470ff feat(project): add project trust and restricted execution mode (#517)
Projects now open with a user-owned trust decision. The prompt offers Preview in Restricted Mode, Don't Open and Trust Project, with an optional trusted parent folder; preview is the default focus so Enter never grants trust.

Restricted projects keep browsing, editing, saving, search and highlighting. Project-controlled execution is denied by default in the backend: run, debug, build, tools, language servers, plugins and Git commands that could honour repository configuration. Trusting a project enables those paths without reopening it, and decisions can be revoked in Settings.

Terminals stay available when the user opens one, without project shell settings, environment or virtualenv activation. A shell prompt that runs git in the folder can still honour the repository's core.fsmonitor.

Fixes #462
2026-10-31 20:58:45 +08:00

202 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { menuGroupItems, registerMenuGroup } from "@/workbench/contributions";
import type { MenuItem } from "@/ui";
import { choiceItems, contextMenuItems } from "./contextRun";
import { OPTIMISTIC, produceContextChoices } from "./producers";
import { gutterTestState, markerIcon, targetMenuItems } from "./runGutter";
import { useRun } from "./store";
import type { RunTarget } from "./targets";
import { TestResults } from "./testing";
import { clearTestStates, recordTestStates } from "./testing/states";
const ROOT = "/work/app";
const SCRATCHES = "/Users/me/scratches";
const npmTest: RunConfig = {
id: "npm:test",
name: "test",
kind: "npm",
command: "npm",
args: ["run", "test"],
cwd: null,
env: {},
detected: true,
isTest: true,
debugAdapter: "js-debug",
};
type Item = ReturnType<typeof contextMenuItems>[number];
// Flattens "More Run/Debug" so its entries read in menu order.
const labels = (items: Item[]): string[] =>
items.flatMap((i) =>
i.kind === "action"
? [`${i.action}|${i.label}|${i.icon}`]
: i.kind === undefined || i.kind === "item"
? "submenu" in i && i.submenu
? labels(i.submenu)
: [i.label]
: [i.kind],
);
describe("run context menu group", () => {
beforeEach(() => {
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
useRun.setState({ configs: [npmTest], loaded: true });
});
afterEach(() => {
useProject.setState({ project: null });
useRun.setState({ configs: [], loaded: false });
});
it("editor: Run / Debug '<file>' and More Run/Debug › Modify Run Configuration… for a runnable scratch", () => {
const items = contextMenuItems("editor", { paths: [`${SCRATCHES}/hi.ts`], text: "console.log(123)", line: 0 });
expect(labels(items)).toEqual([
"run.runContext|Run 'hi.ts'|run",
"run.debugContext|Debug 'hi.ts'|debug",
"run.modifyContext|Modify Run Configuration…|edit",
]);
// The actions get the context (path + caret line), so the menu runs what it shows.
expect(items[0]).toMatchObject({ arg: { path: `${SCRATCHES}/hi.ts`, line: 0, dir: false } });
});
it("editor: the element at the caret names the entries", () => {
const text = "describe('math', () => {\n it('adds', () => {\n expect(1).toBe(1);\n });\n});\n";
const at = (line: number) => contextMenuItems("editor", { paths: [`${ROOT}/src/m.test.ts`], text, line })[0];
expect(at(2)).toMatchObject({ label: "Run 'math > adds'" });
expect(at(0)).toMatchObject({ label: "Run 'math'" });
});
it("tabs and the project view use the file, not the caret", () => {
const text = "describe('math', () => {\n it('adds', () => {});\n});\n";
const tab = contextMenuItems("editorTab", { paths: [`${ROOT}/src/m.test.ts`], text, line: 1 });
expect(tab[0]).toMatchObject({ label: "Run 'm.test.ts'" });
const view = contextMenuItems("projectView", { paths: [`${ROOT}/src/m.test.ts`], dirs: [false] });
expect(view[1]).toMatchObject({ label: "Debug 'm.test.ts'" });
});
it("project view: test directories", () => {
const items = contextMenuItems("projectView", { paths: [`${ROOT}/src/__tests__`], dirs: [true] });
expect(items[0]).toMatchObject({ label: "Run 'Tests in '__tests__''", arg: { dir: true } });
expect(contextMenuItems("projectView", { paths: [`${ROOT}/src/components`], dirs: [true] })).toEqual([]);
});
it("shows nothing when no producer matches or for several paths", () => {
expect(contextMenuItems("editor", { paths: [`${ROOT}/README.md`], text: "# hi", line: 0 })).toEqual([]);
expect(contextMenuItems("projectView", { paths: [`${ROOT}/a.js`, `${ROOT}/b.js`], dirs: [false, false] })).toEqual(
[],
);
useProject.setState({ project: null });
expect(contextMenuItems("editor", { paths: [`${SCRATCHES}/hi.ts`], text: "", line: 0 })).toEqual([]);
});
});
describe("menuGroupItems", () => {
it("collects contributed groups in order, each followed by a separator", () => {
const off = [
registerMenuGroup({ id: "b", menus: ["editor"], order: 2, items: () => [{ label: "B" }] }),
registerMenuGroup({ id: "a", menus: ["editor", "editorTab"], order: 1, items: () => [{ label: "A" }] }),
registerMenuGroup({ id: "empty", menus: ["editor"], items: () => [] }),
registerMenuGroup({
id: "broken",
menus: ["editor"],
items: () => {
throw new Error("x");
},
}),
];
const prev = console.error;
console.error = () => {};
try {
expect(menuGroupItems("editor", { paths: ["/a"] })).toEqual([
{ label: "A" },
{ kind: "separator" },
{ label: "B" },
{ kind: "separator" },
]);
expect(menuGroupItems("editorTab", { paths: ["/a"] })).toEqual([{ label: "A" }, { kind: "separator" }]);
expect(menuGroupItems("projectView", { paths: ["/a"] })).toEqual([]);
} finally {
console.error = prev;
off.forEach((d) => d());
}
expect(menuGroupItems("editor", { paths: ["/a"] })).toEqual([]);
});
});
describe("debug entry", () => {
beforeEach(() => {
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
useRun.setState({ configs: [], loaded: true });
});
afterEach(() => useProject.setState({ project: null }));
it("is left out when nothing can debug the file", () => {
const sh = contextMenuItems("projectView", { paths: [`${SCRATCHES}/a.sh`], dirs: [false] });
expect(labels(sh)).toEqual(["run.runContext|Run 'a.sh'|run", "run.modifyContext|Modify Run Configuration…|edit"]);
const rs = contextMenuItems("editor", { paths: [`${SCRATCHES}/a.rs`], text: "fn main() {}", line: 0 });
expect(labels(rs)[1]).toBe("run.debugContext|Debug 'a.rs'|debug");
});
});
describe("run gutter popup", () => {
const unit: RunConfig = { ...npmTest, id: "npm:test:unit", name: "npm run test:unit", args: ["run", "test:unit"] };
const text = "describe('math', () => {\n it('adds', () => {\n });\n});\n";
const adds: RunTarget = { line: 1, kind: "test", name: "math > adds", path: ["math", "adds"] };
const itemLabels = (items: MenuItem[]): string[] =>
items.map((i) =>
i.kind === "separator" ? "---" : "label" in i ? `${i.label}${"submenu" in i && i.submenu ? " ▸" : ""}` : "",
);
beforeEach(() => {
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
useRun.setState({ configs: [npmTest], loaded: true });
});
afterEach(() => {
useProject.setState({ project: null });
useRun.setState({ configs: [], loaded: false });
clearTestStates();
});
it("ends with Modify Run Configuration… after the executors", () => {
const items = targetMenuItems(adds, `${ROOT}/src/m.test.ts`, text);
expect(itemLabels(items)).toEqual(["Run 'math > adds'", "Debug 'math > adds'", "---", "Modify Run Configuration…"]);
});
it("lists each matching test configuration when several match", () => {
useRun.setState({ configs: [npmTest, unit] });
const items = targetMenuItems(adds, `${ROOT}/src/m.test.ts`, text);
expect(itemLabels(items).slice(0, 2)).toEqual(["Run 'math > adds' ▸", "Debug 'math > adds' ▸"]);
const run = items[0] as Extract<MenuItem, { submenu?: MenuItem[] }>;
expect(run.submenu!.map((i) => ("shortcut" in i ? i.shortcut : null))).toEqual(["test", "npm run test:unit"]);
// ⌃⇧R's chooser lists the same, under a header.
expect(
choiceItems(
produceContextChoices(
{ path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: [npmTest, unit], content: text, line: 1 },
OPTIMISTIC,
),
false,
)[0],
).toEqual({
kind: "header",
label: "Choose configuration to run",
});
});
it("icons follow the last test result", () => {
const path = `${ROOT}/src/m.test.ts`;
expect(markerIcon("test", gutterTestState(adds, path))).toBe("gutter-run");
const r = new TestResults();
r.apply([{ type: "result", path: ["src/m.test.ts", "math", "adds"], status: "failed" }]);
recordTestStates(r);
expect(markerIcon("test", gutterTestState(adds, path))).toBe("gutter-run-failed");
expect(markerIcon("suite", gutterTestState({ ...adds, kind: "suite", name: "math", path: ["math"] }, path))).toBe(
"gutter-run-failed",
);
// `main` targets never show results.
expect(gutterTestState({ line: 0, kind: "main", name: "main", path: [] }, path)).toBeNull();
});
});