mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e14395969 | ||
|
|
f5895d0336 | ||
|
|
c3c1d88d10 | ||
|
|
a0ac988963 |
No files matched your search
@@ -3,7 +3,9 @@ name: Language smoke
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
# Then running and testing each fixture through the run store and openidea-server
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
|
||||
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
|
||||
# Step Over, Resume): smoke/debug.test.ts.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -17,6 +19,7 @@ on:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src/features/run/**"
|
||||
- "src/features/debugger/**"
|
||||
- "src/platform/**"
|
||||
- "smoke/**"
|
||||
- "package.json"
|
||||
@@ -108,3 +111,6 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server
|
||||
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
|
||||
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
|
||||
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
|
||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||
@@ -71,6 +71,9 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
|
||||
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
|
||||
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
|
||||
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
|
||||
`sudo DevToolsSecurity -enable`).
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
|
||||
@@ -36,6 +36,23 @@ impl Default for InspectionSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl InspectionSettings {
|
||||
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
|
||||
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
|
||||
let custom = |level: &str, key: &str| match level {
|
||||
"ide" => self.ide_profiles.get(key),
|
||||
"project" => self.project_profiles.get(key),
|
||||
_ => None,
|
||||
};
|
||||
match self.profile.as_str() {
|
||||
"project" => &self.project,
|
||||
id => {
|
||||
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -111,4 +128,20 @@ mod tests {
|
||||
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_profile_falls_back_to_default() {
|
||||
let off = |id: &str| {
|
||||
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
|
||||
};
|
||||
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
|
||||
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
|
||||
assert!(s.active().contains_key("a"));
|
||||
s.profile = "project".into();
|
||||
assert!(s.active().contains_key("b"));
|
||||
s.profile = "ide:strict".into();
|
||||
assert!(s.active().contains_key("c"));
|
||||
s.profile = "project:gone".into();
|
||||
assert!(s.active().contains_key("a"));
|
||||
}
|
||||
}
|
||||
@@ -589,11 +589,12 @@ impl DebugManager {
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
|
||||
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
|
||||
if config.adapter == "debugpy" {
|
||||
let python = match arguments.get("python") {
|
||||
Some(Value::String(p)) => Some(p.clone()),
|
||||
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
|
||||
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
|
||||
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(python) = python {
|
||||
|
||||
@@ -175,7 +175,12 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
|
||||
let code_style = key == "editor"
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
|
||||
@@ -209,6 +209,7 @@ impl Host {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
|
||||
use crate::exec::{path_with, search_path};
|
||||
use crate::{angular, godot, pinned, vue};
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
use ide_core::settings::{LanguageServerSettings, Settings};
|
||||
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
|
||||
use ide_core::version_managers::Tool;
|
||||
use ide_language::{LanguageConfig, LanguageServerConfig};
|
||||
use ide_run::RunConfig;
|
||||
@@ -442,15 +442,41 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
|
||||
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
|
||||
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
|
||||
|
||||
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
|
||||
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
|
||||
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
|
||||
if o.enabled != Some(true) {
|
||||
return None;
|
||||
}
|
||||
Some(match o.severity {
|
||||
Some(InspectionLevel::Error) => "error",
|
||||
None | Some(InspectionLevel::Warning) => "warning",
|
||||
Some(_) => "info",
|
||||
})
|
||||
}
|
||||
|
||||
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
|
||||
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
|
||||
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
|
||||
/// Each variant has its own file, so changing an inspection restarts the server with it.
|
||||
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
|
||||
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
|
||||
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
|
||||
let serial = missing_serial_version(inspections);
|
||||
let (name, content) = match serial {
|
||||
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
|
||||
Some(level) => (
|
||||
format!("compiler-defaults-serial-{level}.prefs"),
|
||||
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
|
||||
),
|
||||
};
|
||||
let file = data_dir.join("lsp/jdtls").join(name);
|
||||
let write = || -> ide_core::IdeResult<()> {
|
||||
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
|
||||
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
|
||||
ide_vfs::write_atomic(&file, content.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
@@ -1095,13 +1121,50 @@ mod java_compiler_tests {
|
||||
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_version_inspection_sets_the_compiler_option() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let url = |inspections: &InspectionSettings| {
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
configure_java_compiler(&mut cfg, tmp.path(), inspections);
|
||||
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
|
||||
};
|
||||
let set = |o: ide_core::settings::InspectionOverride| {
|
||||
let mut s = InspectionSettings::default();
|
||||
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
|
||||
s
|
||||
};
|
||||
let off = url(&InspectionSettings::default());
|
||||
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
|
||||
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
|
||||
assert_ne!(on, off, "a new file, so the server restarts with it");
|
||||
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
|
||||
let error = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
|
||||
let weak = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::WeakWarning),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
|
||||
let severity_only = url(&set(ide_core::settings::InspectionOverride {
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(severity_only, off, "off by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cache = tmp.path().join("cache with spaces 测试");
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
let options = options(&cfg);
|
||||
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
|
||||
assert!(path.starts_with(&cache));
|
||||
@@ -1112,7 +1175,7 @@ mod java_compiler_tests {
|
||||
}
|
||||
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
|
||||
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
|
||||
assert_eq!(self::options(&cfg), options);
|
||||
}
|
||||
@@ -1122,7 +1185,7 @@ mod java_compiler_tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
|
||||
configure_java_compiler(&mut cfg, tmp.path());
|
||||
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
|
||||
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
|
||||
|
||||
for settings in [
|
||||
|
||||
@@ -369,7 +369,6 @@ pub fn configure_jdtls(
|
||||
if cfg.name != "jdtls" {
|
||||
return;
|
||||
}
|
||||
crate::apply::configure_java_compiler(cfg, data_dir);
|
||||
// A Lombok agent from the user's JVM arguments wins.
|
||||
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
|
||||
let agent = own_agent.as_deref().or(lombok);
|
||||
|
||||
+4
-3
@@ -20,9 +20,10 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
|
||||
|
||||
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
|
||||
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
|
||||
language servers, and `smoke/run.test.ts` runs each project's run and test configurations
|
||||
(the intentionally failing test included, then Rerun Failed Tests): when you change a
|
||||
fixture, run both for that language.
|
||||
language servers, `smoke/run.test.ts` runs each project's run and test configurations
|
||||
(the intentionally failing test included, then Rerun Failed Tests), and `smoke/debug.test.ts`
|
||||
debugs its entry point from the "Set a breakpoint here" line: when you change a fixture, run
|
||||
them for that language.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
|
||||
* entry point's "Set a breakpoint here" line, Debug from the file's context, then the paused
|
||||
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
||||
import { get } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { commands, type InstallEvent } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, nextRequestId, onIdeEvent } from "@/platform/ipc";
|
||||
import { useBuildModel } from "@/platform/buildModel";
|
||||
import { openProject, useProject } from "@/platform/project";
|
||||
import { activate as activateBuildTools } from "@/features/build-tools";
|
||||
import { useBuild } from "@/features/build-tools/store";
|
||||
import { activate as activateDebugger } from "@/features/debugger";
|
||||
import { addBreakpoint } from "@/features/debugger/breakpoints/store";
|
||||
import { activeSession, evaluate, useDebug } from "@/features/debugger/state";
|
||||
import { activate as activateRun } from "@/features/run";
|
||||
import { resetRuns, useRun } from "@/features/run/store";
|
||||
import { activateWorkbench } from "@/workbench/activate";
|
||||
import { useNotifications } from "@/workbench/notificationStore";
|
||||
import { connectDesktop } from "./desktop";
|
||||
import { repoRoot, Server } from "./server";
|
||||
|
||||
interface Case {
|
||||
fixture: string;
|
||||
/** Runs in the project copy first (installs its dependencies). */
|
||||
setup?: (root: string) => void;
|
||||
/** The IDE's tool that debugs it. */
|
||||
debugger: string;
|
||||
/** The entry point, debugged from its context. */
|
||||
file: string;
|
||||
/** A variable of the paused frame. */
|
||||
variable: string;
|
||||
/** An expression evaluated there (Evaluate Expression), and its value. */
|
||||
evaluate: [string, RegExp];
|
||||
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
|
||||
request?: string;
|
||||
}
|
||||
|
||||
const CASES: Record<string, Case> = {
|
||||
typescript: {
|
||||
fixture: "ts-app",
|
||||
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "js-debug",
|
||||
file: "src/index.ts",
|
||||
variable: "cart",
|
||||
evaluate: ["cart.items.length", /^2$/],
|
||||
},
|
||||
python: {
|
||||
fixture: "python-app",
|
||||
setup: (root) => execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "debugpy",
|
||||
file: "src/tasks/cli.py",
|
||||
variable: "tasks",
|
||||
evaluate: ["len(tasks.pending())", /^3$/],
|
||||
},
|
||||
rust: {
|
||||
fixture: "rust-app",
|
||||
debugger: "codelldb",
|
||||
file: "src/main.rs",
|
||||
variable: "inventory",
|
||||
evaluate: ["inventory", /\S/],
|
||||
},
|
||||
go: {
|
||||
fixture: "go-app",
|
||||
debugger: "delve",
|
||||
file: "cmd/server/main.go",
|
||||
variable: "r",
|
||||
evaluate: ["r.Method", /^"GET"$/],
|
||||
request: "http://127.0.0.1:8080/notes?q=openidea",
|
||||
},
|
||||
java: {
|
||||
fixture: "java-app",
|
||||
debugger: "java-debug",
|
||||
file: "src/main/java/com/example/orders/App.java",
|
||||
variable: "book",
|
||||
evaluate: ["book.size()", /^3$/],
|
||||
},
|
||||
kotlin: {
|
||||
fixture: "kotlin-gradle",
|
||||
debugger: "kotlin-debug-adapter",
|
||||
file: "src/main/kotlin/com/example/library/App.kt",
|
||||
variable: "library",
|
||||
evaluate: ["library", /Library/],
|
||||
},
|
||||
};
|
||||
|
||||
let deactivate: (() => void) | null = null;
|
||||
let server: Server | null = null;
|
||||
let disconnect: (() => void) | null = null;
|
||||
let base = "";
|
||||
|
||||
/** Backend events received, for failure messages. */
|
||||
const events: string[] = [];
|
||||
beforeAll(() => {
|
||||
onIdeEvent("*", (e) => events.push(e.type === "debug" ? `debug ${e.session} ${e.event.kind}` : e.type));
|
||||
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
|
||||
deactivate = () => disposers.reverse().forEach((d) => d());
|
||||
});
|
||||
afterAll(() => deactivate?.());
|
||||
|
||||
afterEach(() => {
|
||||
resetRuns();
|
||||
disconnect?.();
|
||||
server?.close();
|
||||
if (base) rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function open(c: Case): Promise<string> {
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
|
||||
const root = join(base, c.fixture);
|
||||
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
|
||||
c.setup?.(root);
|
||||
server = await Server.start(join(base, "home"));
|
||||
disconnect = connectDesktop(server);
|
||||
await openProject(root);
|
||||
const trusted = await commands.projectTrustSet(root, true, false);
|
||||
if (trusted.status !== "ok") throw new Error(trusted.error.message);
|
||||
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
|
||||
// Run and Debug follow the build (a Gradle project's Application runs through Gradle).
|
||||
await until(
|
||||
"the build model",
|
||||
() => {
|
||||
const error = useBuild.getState().error;
|
||||
if (error) throw new Error(`the build model did not load: ${error}`);
|
||||
return useBuildModel.getState().root === root && useBuildModel.getState().model;
|
||||
},
|
||||
5 * 60_000,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function install(tool: string) {
|
||||
const tools = await call(commands.toolsList());
|
||||
if (tools.find((t) => t.name === tool)?.status === "installed") return;
|
||||
const channel = new Channel<InstallEvent>();
|
||||
const info = await call(commands.toolsInstall(tool, nextRequestId(), channel));
|
||||
expect(info.status, `${tool} installs`).toBe("installed");
|
||||
}
|
||||
|
||||
/** What the Debug and Run tool windows show, for failure messages. */
|
||||
function shown(): string {
|
||||
const { sessions, console } = useDebug.getState();
|
||||
const debug = Object.values(sessions).map(
|
||||
(s) =>
|
||||
`debug ${s.id} ${s.name} ${s.status} ${s.stopReason ?? ""}\n` +
|
||||
(console[s.id] ?? [])
|
||||
.slice(-40)
|
||||
.map((l) => ` ${l.text}`)
|
||||
.join(""),
|
||||
);
|
||||
const runs = useRun.getState().sessions.map((s) => `run ${s.id} ${s.config.name} ${s.status} ${s.exitCode ?? ""}`);
|
||||
const notes = useNotifications.getState().items.map((n) => `${n.level}: ${n.title} ${n.body ?? ""}`);
|
||||
return [...debug, ...runs, ...notes, `events: ${events.slice(-60).join(", ")}`, server?.log() ?? ""].join("\n");
|
||||
}
|
||||
|
||||
/** Waits for `value` to be truthy; fails with what the tool windows show. */
|
||||
async function until<T>(what: string, value: () => T | null | undefined | false, timeoutMs = 60_000): Promise<T> {
|
||||
const end = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const v = value();
|
||||
if (v) return v;
|
||||
if (Date.now() > end) throw new Error(`timed out waiting for ${what}\n${shown()}`);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests `url` once the debugged server accepts connections (the response waits for Resume). */
|
||||
async function requestWhenListening(url: string) {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const req = get(url, (res) => {
|
||||
res.resume();
|
||||
resolve(true);
|
||||
});
|
||||
req.on("socket", (socket) => socket.on("connect", () => resolve(true)));
|
||||
req.on("error", () => resolve(false));
|
||||
});
|
||||
if (connected) return;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
const paused = () => {
|
||||
const s = activeSession();
|
||||
return s?.status === "paused" && s.frames.length > 0 ? s : null;
|
||||
};
|
||||
|
||||
/** Paused, failing as soon as every session has ended instead. */
|
||||
const pausedOrEnded = () => {
|
||||
const sessions = Object.values(useDebug.getState().sessions);
|
||||
if (sessions.length > 0 && sessions.every((s) => s.status === "terminated"))
|
||||
throw new Error(`the debugger ended before the breakpoint\n${shown()}`);
|
||||
return paused();
|
||||
};
|
||||
|
||||
async function smoke(language: string) {
|
||||
const c = CASES[language]!;
|
||||
const root = await open(c);
|
||||
await install(c.debugger);
|
||||
|
||||
const path = join(root, c.file);
|
||||
const content = readFileSync(path, "utf8");
|
||||
const marker = content.split("\n").findIndex((l) => l.includes("Set a breakpoint here"));
|
||||
const line = marker + 1;
|
||||
addBreakpoint(path, line);
|
||||
|
||||
await executeAction("run.debugContext", { path, content });
|
||||
if (c.request) void requestWhenListening(c.request);
|
||||
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
|
||||
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
|
||||
const top = s.frames[0]!;
|
||||
expect(top.path).toBe(path);
|
||||
expect(top.line).toBe(line);
|
||||
|
||||
const scopes = await call(commands.debugScopes(s.id, top.id));
|
||||
const locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
|
||||
expect(locals.map((v) => v.name)).toContain(c.variable);
|
||||
|
||||
const value = await evaluate(c.evaluate[0], "repl", s);
|
||||
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
|
||||
|
||||
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
|
||||
// (and its breakpoint) again.
|
||||
for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) {
|
||||
const stops = useDebug.getState().stopSeq;
|
||||
await executeAction("debug.stepOver");
|
||||
await until("Step Over", () => useDebug.getState().stopSeq > stops && paused());
|
||||
}
|
||||
const after = paused()!;
|
||||
expect(after.stopReason, shown()).toBe("step");
|
||||
expect(after.frames[0]!.line, shown()).toBeGreaterThan(line);
|
||||
|
||||
await executeAction("debug.resume");
|
||||
if (c.request) {
|
||||
await until("the request to be served", () => useDebug.getState().sessions[s.id]?.status === "running");
|
||||
await executeAction("debug.stop");
|
||||
}
|
||||
// Every session ends (a JVM that listens again as its debugger detaches may be attached once more).
|
||||
await until("the end", () => Object.values(useDebug.getState().sessions).every((d) => d.status === "terminated"));
|
||||
}
|
||||
|
||||
for (const language of Object.keys(CASES)) {
|
||||
test(language, () => smoke(language));
|
||||
}
|
||||
+11
-7
@@ -3,23 +3,25 @@
|
||||
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
|
||||
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
|
||||
*/
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setInvokeRoute } from "@/platform/tauriCore";
|
||||
import { encodeArgs } from "@/platform/remote";
|
||||
import { Server } from "./server";
|
||||
|
||||
type Callback = (message: unknown) => void;
|
||||
|
||||
// One runtime per page, as in the app: the frontend registers its event listeners once
|
||||
// (`src/platform/ipc.ts`), so they outlive the servers connected one after another.
|
||||
// One runtime per page, there from the start as in the app: the frontend registers its event
|
||||
// listener once (`src/platform/ipc.ts`), possibly before a server is connected, and keeps it
|
||||
// across the servers connected one after another.
|
||||
const callbacks = new Map<number, Callback>();
|
||||
const listeners: { event: string; handler: number }[] = [];
|
||||
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
|
||||
let nextCallback = 1;
|
||||
let installed = false;
|
||||
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
|
||||
const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
|
||||
|
||||
function install() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
{
|
||||
const internals = {
|
||||
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
|
||||
transformCallback(cb: Callback) {
|
||||
@@ -38,6 +40,9 @@ function install() {
|
||||
return listeners.length;
|
||||
}
|
||||
if (cmd === "plugin:window|get_all_windows") return [];
|
||||
// This machine's directories (`@tauri-apps/api/path`), as on the desktop.
|
||||
if (cmd === "plugin:path|resolve_directory") return DIRECTORIES[args.directory as number] ?? null;
|
||||
if (cmd === "plugin:path|join") return join(...(args.paths as string[]));
|
||||
if (cmd.startsWith("plugin:")) return null;
|
||||
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
|
||||
},
|
||||
@@ -50,7 +55,6 @@ function install() {
|
||||
|
||||
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
|
||||
export function connectDesktop(server: Server): () => void {
|
||||
install();
|
||||
channels.clear();
|
||||
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
|
||||
server.onEvent = (target, event) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* JSON-RPC with `Content-Length` framing over the server's stdin / stdout.
|
||||
*/
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const PROTOCOL_VERSION = 2;
|
||||
@@ -35,6 +36,8 @@ export class Server {
|
||||
private constructor(private readonly child: ChildProcessWithoutNullStreams) {
|
||||
child.stdout.on("data", (chunk: Buffer) => this.receive(chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
// The whole log, when asked for (OPENIDEA_SMOKE_LOG=<file>; OPENIDEA_LOG sets its level).
|
||||
if (process.env.OPENIDEA_SMOKE_LOG) appendFileSync(process.env.OPENIDEA_SMOKE_LOG, chunk);
|
||||
this.stderr.push(chunk.toString());
|
||||
if (this.stderr.length > 200) this.stderr.shift();
|
||||
});
|
||||
|
||||
@@ -79,6 +79,27 @@ describe("run config → launch config", () => {
|
||||
expect(extra(globalPytest).python).toBe("python3");
|
||||
});
|
||||
|
||||
it("debugpy: uv / poetry run the debuggee in the project's environment", () => {
|
||||
const debug = (command: string, args: string[]) =>
|
||||
launchConfigFor(rc({ kind: "python", command, args, debugAdapter: "debugpy" }), "/p")!;
|
||||
|
||||
const script = debug("uv", ["run", "python", "main.py", "-v"]);
|
||||
expect(script.program).toBe("/p/main.py");
|
||||
expect(script.args).toEqual(["-v"]);
|
||||
expect(extra(script)).toMatchObject({ python: ["uv", "run", "python"], debugLauncherPython: "python3" });
|
||||
expect(extra(script).module).toBeUndefined();
|
||||
|
||||
const mod = debug("poetry", ["run", "python3", "-m", "app"]);
|
||||
expect(extra(mod)).toMatchObject({ module: "app", python: ["poetry", "run", "python3"] });
|
||||
|
||||
const tool = debug("uv", ["run", "pytest", "-x"]);
|
||||
expect(extra(tool)).toMatchObject({ module: "pytest", python: ["uv", "run", "python"] });
|
||||
expect(tool.args).toEqual(["-x"]);
|
||||
|
||||
const django = debug("pdm", ["run", "python", "manage.py", "runserver"]);
|
||||
expect(extra(django)).toMatchObject({ django: true, python: ["pdm", "run", "python"] });
|
||||
});
|
||||
|
||||
it("js-debug: npm scripts run through the package manager, node scripts directly", () => {
|
||||
const npm = launchConfigFor(
|
||||
rc({ kind: "npm", command: "pnpm", args: ["run", "dev"], cwd: "web", debugAdapter: "js-debug" }),
|
||||
|
||||
@@ -58,15 +58,20 @@ function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig
|
||||
};
|
||||
}
|
||||
|
||||
const PYTHON_NAME = /^python[\d.]*(\.exe)?$/;
|
||||
|
||||
function pythonFor(rc: RunConfig, cwd: string): string {
|
||||
const cmd = rc.command;
|
||||
const name = cmd.split("/").pop() ?? cmd;
|
||||
if (/^python[\d.]*(\.exe)?$/.test(name)) return resolveCommand(cmd, cwd);
|
||||
if (PYTHON_NAME.test(name)) return resolveCommand(cmd, cwd);
|
||||
// A venv tool such as `.venv/bin/pytest`: use the interpreter next to it.
|
||||
if (cmd.includes("/")) return resolveCommand(cmd.slice(0, cmd.lastIndexOf("/") + 1) + "python", cwd);
|
||||
return "python3";
|
||||
}
|
||||
|
||||
/** Project managers whose `run` executes in the project's environment (`uv run python …`). */
|
||||
const PYTHON_RUNNERS = new Set(["uv", "poetry", "pdm", "pipenv", "hatch", "rye"]);
|
||||
|
||||
function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
const out = base(rc, "debugpy", cwd);
|
||||
const dbg = workflowSettings().debugger;
|
||||
@@ -78,19 +83,29 @@ function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
redirectOutput: true,
|
||||
};
|
||||
if (dbg.python.gevent) extra.gevent = true;
|
||||
const name = rc.command.split("/").pop() ?? rc.command;
|
||||
const isPython = /^python[\d.]*(\.exe)?$/.test(name);
|
||||
if (!isPython) {
|
||||
const name = (rc.command.split("/").pop() ?? rc.command).replace(/\.exe$/, "");
|
||||
let args = rc.args;
|
||||
let tool: string | null = PYTHON_NAME.test(name) ? null : name;
|
||||
if (PYTHON_RUNNERS.has(name) && args[0] === "run" && args[1]) {
|
||||
// The debuggee runs through the manager (`uv run python <debugpy> …`) to get the project's
|
||||
// environment; the adapter and its launcher need a plain interpreter.
|
||||
const python = PYTHON_NAME.test(args[1]) ? args[1] : "python";
|
||||
extra.python = [resolveCommand(rc.command, cwd), "run", python];
|
||||
extra.debugLauncherPython = "python3";
|
||||
tool = PYTHON_NAME.test(args[1]) ? null : args[1];
|
||||
args = args.slice(2);
|
||||
}
|
||||
if (tool) {
|
||||
// A console script (`pytest`, `uvicorn`, …): run it as a module.
|
||||
extra.module = name.replace(/\.exe$/, "");
|
||||
out.args = [...rc.args];
|
||||
} else if (rc.args[0] === "-m" && rc.args[1]) {
|
||||
extra.module = rc.args[1];
|
||||
out.args = rc.args.slice(2);
|
||||
} else if (rc.args[0]) {
|
||||
out.program = join(cwd, rc.args[0]);
|
||||
out.args = rc.args.slice(1);
|
||||
if (rc.args[0].endsWith("manage.py")) extra.django = true;
|
||||
extra.module = tool;
|
||||
out.args = [...args];
|
||||
} else if (args[0] === "-m" && args[1]) {
|
||||
extra.module = args[1];
|
||||
out.args = args.slice(2);
|
||||
} else if (args[0]) {
|
||||
out.program = join(cwd, args[0]);
|
||||
out.args = args.slice(1);
|
||||
if (args[0].endsWith("manage.py")) extra.django = true;
|
||||
}
|
||||
out.extraJson = JSON.stringify(extra);
|
||||
return out;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type InspectionNode,
|
||||
} from "../pages/editor/inspectionsModel";
|
||||
import { splitPageTarget } from "../model/staging";
|
||||
import { validateInspections } from "@/platform/inspections";
|
||||
import { JAVA_SERIAL_VERSION_INSPECTION, validateInspections } from "@/platform/inspections";
|
||||
import { NATURAL_LANGUAGES_PAGE, SPELLING_PAGE } from "../pages/editor/spelling";
|
||||
import { INSPECTIONS_PAGE } from "../pages/editor/inspections";
|
||||
import { EDITOR_PAGES } from "../pages/editor";
|
||||
@@ -69,6 +69,7 @@ describe("inspections tree", () => {
|
||||
expect(tree.map((g) => g.name)).toEqual([
|
||||
"Cargo",
|
||||
"General",
|
||||
"Java",
|
||||
"JavaScript",
|
||||
"Other",
|
||||
"Proofreading",
|
||||
@@ -109,6 +110,19 @@ describe("inspections tree", () => {
|
||||
expect(checkState(o, python)).toBe("on");
|
||||
});
|
||||
|
||||
it("keeps an inspection that is off by default off until turned on", () => {
|
||||
const serial = find(tree, `Java/${JAVA_SERIAL_VERSION_INSPECTION}`)!;
|
||||
expect(checkState({}, serial)).toBe("off");
|
||||
let o = setEnabled({}, serial, true);
|
||||
expect(o).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
|
||||
expect(checkState(o, serial)).toBe("on");
|
||||
o = setEnabled(o, serial, false);
|
||||
expect(o).toEqual({});
|
||||
// Turning a whole group on or off only records what differs from each default.
|
||||
const java = find(tree, "group:Java")!;
|
||||
expect(setEnabled({}, java, true)).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
|
||||
});
|
||||
|
||||
it("keeps severity and enabled overrides independent", () => {
|
||||
let o = setLevel({}, "lsp:ruff", "error");
|
||||
o = setEnabled(o, find(tree, "Python/lsp:ruff")!, false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
INSPECTION_LEVELS,
|
||||
codeInspectionId,
|
||||
customProfileId,
|
||||
enabledByDefault,
|
||||
newProfileKey,
|
||||
overridesOf,
|
||||
producerInspectionId,
|
||||
@@ -159,7 +160,7 @@ export function inspectionIds(node: InspectionNode): string[] {
|
||||
return [...(node.id ? [node.id] : []), ...node.children.flatMap(inspectionIds)];
|
||||
}
|
||||
|
||||
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled !== false;
|
||||
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled ?? enabledByDefault(id);
|
||||
|
||||
export type CheckState = "on" | "off" | "mixed";
|
||||
|
||||
@@ -181,9 +182,12 @@ function withOverride(o: ProfileOverrides, id: string, patch: Partial<Inspection
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Turns a node and all its descendants on or off (on is the default, so it removes the override). */
|
||||
/** Turns a node and all its descendants on or off; an inspection back at its default drops the override. */
|
||||
export function setEnabled(o: ProfileOverrides, node: InspectionNode, enabled: boolean): ProfileOverrides {
|
||||
return inspectionIds(node).reduce((acc, id) => withOverride(acc, id, { enabled: enabled ? undefined : false }), o);
|
||||
return inspectionIds(node).reduce(
|
||||
(acc, id) => withOverride(acc, id, { enabled: enabled === enabledByDefault(id) ? undefined : enabled }),
|
||||
o,
|
||||
);
|
||||
}
|
||||
|
||||
/** `undefined` restores the default ("As reported"). */
|
||||
|
||||
@@ -62,9 +62,9 @@
|
||||
.icon {
|
||||
flex: none;
|
||||
}
|
||||
/* The name takes the whole row; when it doesn't fit, the grey detail after it gives way first. */
|
||||
.label {
|
||||
flex: 0 0 auto;
|
||||
max-width: 75%;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -74,6 +74,7 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail {
|
||||
flex: 0 10000 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -190,6 +191,9 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.stashRow > .detail {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.stashMessage {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -216,9 +216,14 @@ export interface BuiltinInspection {
|
||||
description: string;
|
||||
/** Highlighting level when the profile sets none (else as reported). */
|
||||
defaultLevel?: InspectionLevel;
|
||||
/** `false`: off until a profile turns it on. */
|
||||
defaultEnabled?: boolean;
|
||||
options?: readonly InspectionOptionSpec[];
|
||||
}
|
||||
|
||||
/** Turned on, the Java language server's compiler reports it (`ide-sdk` `SERIAL_VERSION_INSPECTION`). */
|
||||
export const JAVA_SERIAL_VERSION_INSPECTION = "java:missing-serial-version";
|
||||
|
||||
/** Producer key of the Typo inspection's diagnostics. */
|
||||
export const SPELLING_PRODUCER = "spelling";
|
||||
export const SPELLING_INSPECTION = "builtin:spelling";
|
||||
@@ -283,6 +288,16 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
|
||||
group: "General",
|
||||
description: "Compiler errors and warnings of the last Build Project, shown at their source positions.",
|
||||
},
|
||||
{
|
||||
id: JAVA_SERIAL_VERSION_INSPECTION,
|
||||
producer: "jdt-serial-version",
|
||||
name: "Serializable class without 'serialVersionUID'",
|
||||
group: "Java",
|
||||
description:
|
||||
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.",
|
||||
defaultLevel: "warning",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "builtin:sql",
|
||||
producer: "sql-inspections",
|
||||
@@ -310,6 +325,11 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
|
||||
const BUILTIN_BY_PRODUCER = new Map(BUILTIN_INSPECTIONS.map((b) => [b.producer, b]));
|
||||
const BUILTIN_BY_ID = new Map(BUILTIN_INSPECTIONS.map((b) => [b.id, b]));
|
||||
|
||||
/** Whether inspection `id` is on when a profile doesn't say. */
|
||||
export function enabledByDefault(id: string): boolean {
|
||||
return BUILTIN_BY_ID.get(id)?.defaultEnabled ?? true;
|
||||
}
|
||||
|
||||
/** Option `key` of inspection `id` under `profile` (its default when unset). */
|
||||
export function inspectionOption(profile: ProfileOverrides, id: string, key: string): boolean {
|
||||
const value = profile[id]?.options?.[key];
|
||||
@@ -363,7 +383,7 @@ export function applyProfile<D extends Diagnostic>(
|
||||
): readonly D[] {
|
||||
const base = producerInspectionId(producer);
|
||||
const own = profile[base];
|
||||
if (own?.enabled === false) return [];
|
||||
if (!(own?.enabled ?? enabledByDefault(base))) return [];
|
||||
const fallback = BUILTIN_BY_PRODUCER.get(producer)?.defaultLevel;
|
||||
let changed = false;
|
||||
const out: D[] = [];
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"Not found": "未找到",
|
||||
"Not found on PATH": "未在 PATH 中找到",
|
||||
"Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。",
|
||||
"Serializable class without 'serialVersionUID'": "没有 'serialVersionUID' 的可序列化类",
|
||||
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.": "报告实现了 Serializable 但没有声明 serialVersionUID 字段的类。在类名上按 Alt+Enter 可添加默认 ID 或生成的 ID。此检查由 Java 语言服务器执行,更改后语言服务器会重启。",
|
||||
"Run `auggie login` in a terminal.": "在终端中运行 `auggie login`。",
|
||||
"Run `claude` in a terminal and sign in.": "在终端中运行 `claude` 并登录。",
|
||||
"Run `codex login` in a terminal.": "在终端中运行 `codex login`。",
|
||||
|
||||
Reference in new issue
Block a user