diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e19b41d..0a5520f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,6 @@ jobs: matrix: os: - ubuntu-latest - - windows-latest steps: - name: Checkout repository uses: actions/checkout@v6 @@ -48,9 +47,8 @@ jobs: matrix: os: - ubuntu-latest - - windows-latest env: - V8TR_CI_TARGET_OS: ${{ runner.os }} + V8TR_CI_TARGET_OS: ${{ matrix.os }} steps: - name: Checkout repository uses: actions/checkout@v6 @@ -73,6 +71,11 @@ jobs: ACTOR: ${{ github.actor }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name || github.repository }} REPOSITORY: ${{ github.repository }} + MATRIX_OS: ${{ matrix.os }} + V8TR_PLATFORM_BUNDLE_URL_LINUX: ${{ secrets.V8TR_PLATFORM_BUNDLE_URL_LINUX }} + V8TR_PLATFORM_BUNDLE_SHA256_LINUX: ${{ secrets.V8TR_PLATFORM_BUNDLE_SHA256_LINUX }} + V8TR_PLATFORM_BUNDLE_URL_WINDOWS: ${{ secrets.V8TR_PLATFORM_BUNDLE_URL_WINDOWS }} + V8TR_PLATFORM_BUNDLE_SHA256_WINDOWS: ${{ secrets.V8TR_PLATFORM_BUNDLE_SHA256_WINDOWS }} run: | trusted="true" @@ -82,6 +85,26 @@ jobs: trusted="false" fi + case "$MATRIX_OS" in + ubuntu-latest) + bundle_url="$V8TR_PLATFORM_BUNDLE_URL_LINUX" + bundle_sha256="$V8TR_PLATFORM_BUNDLE_SHA256_LINUX" + ;; + windows-latest) + bundle_url="$V8TR_PLATFORM_BUNDLE_URL_WINDOWS" + bundle_sha256="$V8TR_PLATFORM_BUNDLE_SHA256_WINDOWS" + ;; + *) + echo "Unsupported matrix.os: $MATRIX_OS" >&2 + exit 2 + ;; + esac + + if [[ -z "$bundle_url" || -z "$bundle_sha256" ]]; then + trusted="false" + echo "V8TR_DESIGNER_ALLOW_MISSING_CONFIG=1" >> "$GITHUB_ENV" + fi + echo "trusted=$trusted" >> "$GITHUB_OUTPUT" if [[ "$trusted" != "true" ]]; then diff --git a/SKILL/references/project-workflows.md b/SKILL/references/project-workflows.md index 76cc98a..5726185 100644 --- a/SKILL/references/project-workflows.md +++ b/SKILL/references/project-workflows.md @@ -120,7 +120,7 @@ v8-runner launch thick v8-runner launch ordinary ``` -Launch onec-client-mcp-devkit through the supported `launch mcp` surface instead of manually assembling `/C"runMcp..."`: +Launch onec-client-mcp-devkit through the supported `launch mcp` surface instead of manually assembling `/C runMcp...`: ```bash v8-runner launch mcp diff --git a/SKILL/references/testing.md b/SKILL/references/testing.md index ba35a37..b82b638 100644 --- a/SKILL/references/testing.md +++ b/SKILL/references/testing.md @@ -73,8 +73,15 @@ EDT syntax: ```bash v8-runner syntax edt +v8-runner syntax edt --project +v8-runner syntax edt --project --exception-file tools/syntax-check-exception-file.txt ``` +Use `--project ` to check one configured EDT source-set by `source-set[].name` +from `v8project.yaml`; it is not the EDT `.project` display name. +Use `--exception-file ` when a project keeps a legacy syntax exception list; each +non-empty line is matched against parsed EDT issues after normalization. + ## Artifacts Preserve failed test artifacts under: diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 24befc3..60e61b7 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -184,7 +184,7 @@ v8-runner test va --feature login --filter-tag @smoke ```bash v8-runner syntax designer-config [FLAGS] v8-runner syntax designer-modules [FLAGS] -v8-runner syntax edt [--project ...] +v8-runner syntax edt [--project ...] [--exception-file ] ``` `designer-config`: @@ -202,8 +202,10 @@ v8-runner syntax edt [--project ...] `edt`: - Только `builder=DESIGNER`, `format=EDT`. -- Повторяемый `--project`. -- Без `--project` использует дефолтный набор EDT-проектов из конфига. +- Повторяемый `--project`; значение выбирается из `source-set[].name` в `v8project.yaml`. +- Без `--project` использует все EDT source-set из конфига. +- `--exception-file` читает legacy-файл исключений: каждая непустая строка исключает + совпавшую EDT issue после нормализации регистра, пробелов и пунктуации. ## Файлы и артефакты @@ -271,16 +273,16 @@ v8-runner launch mcp [va] [--mode ] [FLAGS] - `designer` использует `1cv8`. - `thin` использует `1cv8c`. - `thick` и `ordinary` используют `1cv8`. -- `mcp` запускает клиентский MCP-сервер onec-client-mcp-devkit через `/C"runMcp"`. +- `mcp` запускает клиентский MCP-сервер onec-client-mcp-devkit через `/C runMcp`. - `launch mcp` по умолчанию использует `--mode thin` и `1cv8c`. - `launch mcp --mode thick` использует `1cv8`; `launch mcp --mode ordinary` использует `1cv8` и добавляет `/RunModeOrdinaryApplication`. - `launch mcp va` дополнительно запускает Vanessa Automation из `tools.va` через `/Execute ` и передаёт `VAParams=` без `StartFeaturePlayer`. -- Любой управляемый runner payload для ключа `/C` передаётся как один аргумент - `/C""`: это касается `launch --c`, `launch mcp`, `test yaxunit` и `test va`. +- Любой управляемый runner payload для ключа `/C` передаётся отдельным аргументом + после `/C`: это касается `launch --c`, `launch mcp`, `test yaxunit` и `test va`. - Для `mcp` доступны typed flags `--mcp-config ` и `--mcp-port `; - итоговый payload: `/C"runMcp[=][;mcpPort=]"`. + итоговый payload: `/C runMcp[=][;mcpPort=]`. - Если `--mcp-port` не указан, используется `tools.client_mcp.port` из `v8project.yaml`. - Если настроено `tools.client_mcp.extension`, `launch mcp` не устанавливает и не обновляет его; подготовка выполняется командой `v8-runner build`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5888148..145a061 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -447,7 +447,7 @@ runtime identity и не добавляет отдельное поле конф - `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit. - `extension`, опциональное tool extension для клиентского MCP-сервера. -`launch mcp` передаёт это значение как `mcpPort` внутри `/C"runMcp..."` +`launch mcp` передаёт это значение как `mcpPort` внутри payload после `/C` если CLI не указал `--mcp-port`. `extension` поддерживает: diff --git a/scripts/test/README.md b/scripts/test/README.md index f9f398d..365f527 100644 --- a/scripts/test/README.md +++ b/scripts/test/README.md @@ -152,6 +152,10 @@ live-mcp-http.py bash scripts/test/ci-rust.sh ``` +GitHub Actions currently runs this blocking contract on `ubuntu-latest`. +Re-enabling Windows as blocking requires hardening the existing Unix-assumptive +unit/helper tests first. + ### Happy-path CI helper ```bash diff --git a/spec/acceptance/real-environment-validation.md b/spec/acceptance/real-environment-validation.md index 5d96939..07b540f 100644 --- a/spec/acceptance/real-environment-validation.md +++ b/spec/acceptance/real-environment-validation.md @@ -2,9 +2,9 @@ ## Цель -Начиная с `2026-04-22`, source of truth для real-env happy-path является GitHub Actions workflow [`ci.yml`](../.github/workflows/ci.yml) с matrix на `ubuntu-latest` и `windows-latest`, а локальные скрипты в `scripts/test/*` остаются helper/entrypoint-слоем для этого workflow. +Начиная с `2026-04-22`, source of truth для real-env happy-path является GitHub Actions workflow [`ci.yml`](../.github/workflows/ci.yml), а локальные скрипты в `scripts/test/*` остаются helper/entrypoint-слоем для этого workflow. -Обязательный smoke-контур для обеих ОС один и тот же: +Обязательный smoke-контур: 1. `build` 2. `syntax/check` @@ -47,9 +47,9 @@ bash scripts/test/ci-rust.sh - `V8_RUNNER_CI_SCOPE=runtime-locks` запускает только lock-focused regression subset - `V8_RUNNER_CI_SCOPE=happy-path` запускает обязательную цепочку `build -> syntax/check -> test -> package -> deploy-ready artifacts` -### 2. Mandatory Linux/Windows happy-path +### 2. Mandatory happy-path -Назначение: одинаково обязательный smoke для `Linux` и `Windows` на trusted контексте. +Назначение: обязательный smoke на trusted контексте. Blocking GitHub Actions runner сейчас `ubuntu-latest`; Windows full-test/live path остается TODO до hardening существующих Unix-assumptive тестов и helper-фикстур. Canonical entrypoint: @@ -64,7 +64,7 @@ V8_RUNNER_CI_SCOPE=happy-path bash scripts/test/ci-rust.sh 3. `cargo test --locked` 4. `bash scripts/test/live-cli-fixture.sh` -`scripts/test/live-cli-fixture.sh` в mandatory профиле обязан выполнить одинаковые стадии для обеих ОС: +`scripts/test/live-cli-fixture.sh` в mandatory профиле обязан выполнить стадии: 1. `init/setup infobase` 2. `build --full-rebuild` @@ -158,9 +158,9 @@ python3 scripts/test/live-mcp-http.py bash scripts/test/live-cli-ibcmd.sh ``` -### GitHub Actions matrix +### GitHub Actions -Для `ubuntu-latest` и `windows-latest` blocking использует один и тот же entrypoint: +Blocking path использует entrypoint: ```bash V8_RUNNER_CI_SCOPE=happy-path bash scripts/test/ci-rust.sh @@ -168,9 +168,9 @@ V8_RUNNER_CI_SCOPE=happy-path bash scripts/test/ci-rust.sh Текущая реализация workflow wiring: -- `.github/workflows/ci.yml` публикует два matrix job: `contract` и `happy-path` -- `contract` всегда запускает `bash scripts/test/ci-rust.sh` с `V8_RUNNER_CI_SCOPE=contract` -- `happy-path` всегда запускает `V8_RUNNER_CI_SCOPE=happy-path bash scripts/test/ci-rust.sh` +- `.github/workflows/ci.yml` публикует два job: `contract` и `happy-path` +- `contract` запускает `bash scripts/test/ci-rust.sh` с `V8_RUNNER_CI_SCOPE=contract` +- `happy-path` запускает `V8_RUNNER_CI_SCOPE=happy-path bash scripts/test/ci-rust.sh`; без platform bundle secrets workflow передает `V8TR_DESIGNER_ALLOW_MISSING_CONFIG=1`, поэтому Rust build/check/test остаются blocking, а live fixture завершается soft-skip - trusted path использует `scripts/test/ci-platform-install.sh`, `scripts/test/ci-designer-config.sh` и `scripts/test/ci-ibsrv.sh` - upload deploy-ready артефактов делает только trusted happy-path после успешной non-empty validation в `live-cli-fixture.sh` @@ -194,8 +194,8 @@ Windows runner contract for this helper layer is explicit: | Контур | Linux | Windows | Blocking | Build | Syntax/check | Test | Package | Deploy-ready artifacts | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `ci-rust contract` | yes | yes | yes | Rust | Rust | Rust | no | no | -| `ci-rust happy-path` | yes | yes | yes on trusted | Rust + real 1C | real | Rust by default; real 1C opt-in | real | real | +| `ci-rust contract` | yes | planned | yes | Rust | Rust | Rust | no | no | +| `ci-rust happy-path` | yes | planned | yes on trusted | Rust + real 1C | real | Rust by default; real 1C opt-in | real | real | | `live-mcp-http` | optional | optional | no | real via MCP | real via MCP | real via MCP | n/a | n/a | | `live-cli-ibcmd` | optional | optional | no | real (`IBCMD`) | n/a | n/a | diagnostic dump/export only | n/a | | `live-cli-designer` | optional | optional | no | real (`DESIGNER`) | real | real opt-in | real | real | @@ -207,3 +207,4 @@ Windows runner contract for this helper layer is explicit: - `live-cli-fixture` по умолчанию не запускает 1С test-stage; `va`, `yaxunit-all` и `module` остаются opt-in режимами для стендов, где установлен и проверен соответствующий headless runner. - `live-mcp-http` и `live-cli-ibcmd` остаются отдельными non-blocking контурами. - Mandatory designer smoke requires `V8TR_DESIGNER_REAL_CONFIG`; `V8TR_DESIGNER_ALLOW_MISSING_CONFIG=1` is reserved for fork/non-blocking soft-skip contexts. +- Windows GitHub Actions full-test/live path is intentionally not blocking yet; current TODO is to remove Unix-only path and fake-executable assumptions from tests before re-enabling Windows as blocking. diff --git a/src/change_detection/analyzer.rs b/src/change_detection/analyzer.rs index be2f047..250fbb7 100644 --- a/src/change_detection/analyzer.rs +++ b/src/change_detection/analyzer.rs @@ -78,6 +78,12 @@ pub enum ChangeDetectionError { /// Analyze one source-set context and produce either concrete changes or a safe fallback. pub fn analyze_context(context: &SourceSetContext, work_path: &Path) -> ContextAnalysis { let storage = HashStorage::new(context.storage_path(work_path)); + tracing::debug!( + source_set = %context.name(), + source_path = %context.path().display(), + storage_path = %storage.path().display(), + "starting change analysis" + ); let snapshot = match storage.load_snapshot() { Ok(snapshot) => snapshot, Err(e) => { @@ -100,6 +106,13 @@ pub fn analyze_context(context: &SourceSetContext, work_path: &Path) -> ContextA }; let stored_keys: HashSet = snapshot.entries.keys().cloned().collect(); + tracing::debug!( + source_set = %context.name(), + stored_files = stored_keys.len(), + watermark = snapshot.watermark, + generation = snapshot.generation, + "loaded change-detection snapshot" + ); let scan = match scanner::scan(context.path(), snapshot.watermark, &stored_keys) { Ok(scan) => scan, Err(e) => { @@ -114,6 +127,12 @@ pub fn analyze_context(context: &SourceSetContext, work_path: &Path) -> ContextA }; } }; + tracing::debug!( + source_set = %context.name(), + seen_files = scan.seen_files.len(), + candidate_files = scan.candidates.len(), + "finished source scan" + ); let mut changes = detect_changes(&scan.candidates, &snapshot.entries); let seen_rel: HashSet<&str> = scan diff --git a/src/change_detection/scanner.rs b/src/change_detection/scanner.rs index 04d83d9..f8f9081 100644 --- a/src/change_detection/scanner.rs +++ b/src/change_detection/scanner.rs @@ -83,6 +83,12 @@ pub fn scan( let mut candidates = Vec::new(); let cutoff = watermark.map(|w| w.saturating_sub(COARSE_MARGIN_NS)); + tracing::debug!( + root = %root.display(), + watermark, + stored_files = stored_keys.len(), + "starting filesystem scan" + ); for entry in WalkDir::new(root) .follow_links(false) .into_iter() @@ -143,8 +149,23 @@ pub fn scan( }); } seen_files.push(seen); + if seen_files.len() % 1000 == 0 { + tracing::debug!( + root = %root.display(), + seen_files = seen_files.len(), + candidate_files = candidates.len(), + current_file = %path.display(), + "filesystem scan progress" + ); + } } + tracing::debug!( + root = %root.display(), + seen_files = seen_files.len(), + candidate_files = candidates.len(), + "finished filesystem scan" + ); Ok(ScanSnapshot { scan_started_at, seen_files, diff --git a/src/cli/args.rs b/src/cli/args.rs index 3cb78a0..334edbf 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -333,9 +333,13 @@ pub enum SyntaxTarget { DesignerModules(DesignerModulesSyntaxArgs), /// Check via EDT validate Edt { - /// EDT project names + /// EDT source-set names from v8project.yaml #[arg(long = "project")] projects: Vec, + + /// File with syntax issue exception lines + #[arg(long = "exception-file")] + exception_file: Option, }, } @@ -357,11 +361,11 @@ pub struct LaunchArgs { #[command(flatten)] pub launch: LaunchOptionsArgs, - /// JSON config path for onec-client-mcp-devkit `/C"runMcp="` + /// JSON config path for onec-client-mcp-devkit `/C runMcp=` #[arg(long = "mcp-config")] pub mcp_config: Option, - /// Port override for onec-client-mcp-devkit `/C"...;mcpPort="` + /// Port override for onec-client-mcp-devkit `/C` payload. #[arg(long = "mcp-port")] pub mcp_port: Option, } diff --git a/src/cli/execute.rs b/src/cli/execute.rs index dd6b05f..523aaec 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -1185,8 +1185,12 @@ fn map_syntax_request(args: &SyntaxArgs) -> Result SyntaxTarget::DesignerModules(modules) => { SyntaxTargetRequest::DesignerModules(map_designer_modules_request(modules)?) } - SyntaxTarget::Edt { projects } => SyntaxTargetRequest::Edt { + SyntaxTarget::Edt { + projects, + exception_file, + } => SyntaxTargetRequest::Edt { projects: projects.clone(), + exception_file: exception_file.clone(), }, }, }) diff --git a/src/config/model.rs b/src/config/model.rs index c986256..3cbb3a9 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -281,7 +281,7 @@ impl Default for McpConfig { #[derive(Debug, Clone, Deserialize, Serialize, Default)] #[serde(default, rename_all = "snake_case")] pub struct ClientMcpToolConfig { - /// Default port passed to onec-client-mcp-devkit via `/C"...;mcpPort="`. + /// Default port passed to onec-client-mcp-devkit via `/C` payload. pub port: Option, /// Optional tool extension prepared by `build` for client MCP launches. diff --git a/src/mcp/edt_syntax.rs b/src/mcp/edt_syntax.rs index 23aad7d..5e2bdec 100644 --- a/src/mcp/edt_syntax.rs +++ b/src/mcp/edt_syntax.rs @@ -34,7 +34,7 @@ pub async fn execute( ) -> Result, EdtSyntaxTransportError> { let started = Instant::now(); let projects = match &request.target { - SyntaxTargetRequest::Edt { projects } => projects, + SyntaxTargetRequest::Edt { projects, .. } => projects, _ => { let error = AppError::Validation( "shared EDT syntax executor requires an EDT syntax target".to_owned(), @@ -328,7 +328,7 @@ fn resolve_edt_source_sets<'a>( if !unknown.is_empty() { return Err(AppError::Validation(format!( - "unknown EDT project(s): {}", + "unknown EDT source-set(s): {}", unknown.join(", ") ))); } diff --git a/src/mcp/service.rs b/src/mcp/service.rs index 4a7d003..0ac260b 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -608,6 +608,7 @@ pub(crate) fn normalize_check_syntax_edt_request( SyntaxRequest { target: SyntaxTargetRequest::Edt { projects: normalize_edt_projects(request.project_name.as_deref()), + exception_file: None, }, } } @@ -1470,7 +1471,10 @@ mod tests { let requests = service.port.syntax_requests.borrow(); assert_eq!( requests[0].1.target, - SyntaxTargetRequest::Edt { projects: vec![] } + SyntaxTargetRequest::Edt { + projects: vec![], + exception_file: None + } ); } diff --git a/src/platform/enterprise.rs b/src/platform/enterprise.rs index 08d8b7f..29dbbe6 100644 --- a/src/platform/enterprise.rs +++ b/src/platform/enterprise.rs @@ -157,7 +157,8 @@ pub fn build_launch_args( args.push(execute.clone()); } if let Some(c) = &launch.c { - args.push(quoted_c_arg(c)); + args.push("/C".to_owned()); + args.push(c.clone()); } let mut extra_args = Vec::new(); @@ -182,10 +183,6 @@ fn effective_out_path(launch: &LaunchOptions) -> Option<&str> { launch.internal_out.as_deref().or(launch.out.as_deref()) } -fn quoted_c_arg(payload: &str) -> String { - format!("/C\"{payload}\"") -} - fn filtered_raw_launch_args(args: &[String]) -> Vec { let mut filtered = Vec::new(); let mut skip_value = false; @@ -262,9 +259,8 @@ mod tests { assert_eq!(args[1], "/DisableStartupDialogs"); assert!(args.iter().any(|arg| arg == "/TESTMANAGER")); assert!(args - .iter() - .any(|arg| arg == "/C\"RunUnitTests=/tmp/path with space/тест config.json\"")); - assert!(!args.iter().any(|arg| arg == "/C")); + .windows(2) + .any(|pair| pair == ["/C", "RunUnitTests=/tmp/path with space/тест config.json"])); assert!(args.iter().any(|arg| arg == "/Out")); } @@ -288,10 +284,11 @@ mod tests { .iter() .any(|arg| arg == "/tmp/va/vanessa automation.epf")); assert!(args.iter().any(|arg| arg == "/TESTMANAGER")); - assert!(args - .iter() - .any(|arg| arg == "/C\"StartFeaturePlayer;VAParams=/tmp/va/va-params.json\"")); - assert!(!args.iter().any(|arg| arg == "/C")); + assert!(args.windows(2).any(|pair| pair + == [ + "/C", + "StartFeaturePlayer;VAParams=/tmp/va/va-params.json" + ])); } #[test] diff --git a/src/platform/interactive.rs b/src/platform/interactive.rs index 61df24a..64f95c1 100644 --- a/src/platform/interactive.rs +++ b/src/platform/interactive.rs @@ -1049,7 +1049,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; - use std::time::Duration; + use std::time::{Duration, Instant}; use tempfile::tempdir; #[cfg(unix)] @@ -1295,6 +1295,10 @@ mod tests { #[cfg(unix)] fn is_process_alive(pid: u32) -> bool { + if process_is_zombie(pid) { + return false; + } + Command::new("kill") .args(["-0", &pid.to_string()]) .stderr(std::process::Stdio::null()) @@ -1303,6 +1307,30 @@ mod tests { .unwrap_or(false) } + #[cfg(unix)] + fn process_is_zombie(pid: u32) -> bool { + let stat_path = format!("/proc/{pid}/stat"); + let Ok(stat) = fs::read_to_string(stat_path) else { + return false; + }; + let Some(after_name) = stat.rsplit_once(") ") else { + return false; + }; + after_name.1.starts_with("Z ") + } + + #[cfg(unix)] + fn wait_until_process_exits(pid: u32, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if !is_process_alive(pid) { + return true; + } + thread::sleep(Duration::from_millis(25)); + } + !is_process_alive(pid) + } + #[cfg(unix)] #[test] fn startup_waits_for_prompt() { @@ -1616,9 +1644,8 @@ mod tests { assert!(is_process_alive(child_pid)); executor.kill().expect("kill executor"); - thread::sleep(Duration::from_millis(50)); - assert!(!is_process_alive(child_pid)); + assert!(wait_until_process_exits(child_pid, Duration::from_secs(2))); } #[cfg(unix)] diff --git a/src/support/fs.rs b/src/support/fs.rs index 73556a8..0be7638 100644 --- a/src/support/fs.rs +++ b/src/support/fs.rs @@ -252,10 +252,9 @@ fn lock_holder_is_live(_pid: u32) -> bool { } pub fn best_effort_fsync_dir(path: &Path) -> std::io::Result<()> { - let dir = File::open(path)?; - #[cfg(unix)] unsafe { + let dir = File::open(path)?; let rc = libc::fsync(std::os::fd::AsRawFd::as_raw_fd(&dir)); if rc == 0 { return Ok(()); @@ -270,7 +269,7 @@ pub fn best_effort_fsync_dir(path: &Path) -> std::io::Result<()> { #[cfg(not(unix))] { - let _ = dir; + let _ = path; Ok(()) } } diff --git a/src/use_cases/build_project.rs b/src/use_cases/build_project.rs index 5c2b359..96d214f 100644 --- a/src/use_cases/build_project.rs +++ b/src/use_cases/build_project.rs @@ -281,7 +281,7 @@ fn execute_edt_export_step( )) })?; let export_result = dsl - .export_project(&project_name, designer_context.path()) + .export_project_path(edt_context.path(), designer_context.path()) .map_err(AppError::from)?; let export_log_path = write_edt_export_log( config, @@ -724,7 +724,7 @@ mod tests { }) .unwrap_or_default(); let body = format!( - "args=\"$*\"\nproject=\"\"\ntarget=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--project-name\" ]; then project=\"$arg\"; fi\n if [ \"$prev\" = \"--configuration-files\" ]; then target=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$target\" ]; then mkdir -p \"$target\"; printf 'exported from %s\\n' \"$project\" > \"$target/exported.txt\"; printf '\\n' > \"$target/Configuration.xml\"; fi\nprintf '%s\\n' \"$args\" >> \"{}\"\n{}\nexit 0", + "args=\"$*\"\nproject=\"\"\ntarget=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--project-name\" ] || [ \"$prev\" = \"--project\" ]; then project=\"$arg\"; fi\n if [ \"$prev\" = \"--configuration-files\" ]; then target=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$target\" ]; then mkdir -p \"$target\"; printf 'exported from %s\\n' \"$project\" > \"$target/exported.txt\"; printf '\\n' > \"$target/Configuration.xml\"; fi\nprintf '%s\\n' \"$args\" >> \"{}\"\n{}\nexit 0", calls_log.display(), pattern_branch ); @@ -797,7 +797,7 @@ mod tests { target=\"\"\n\ prev=\"\"\n\ for arg in \"$@\"; do\n\ - if [ \"$prev\" = \"--project-name\" ]; then project=\"$arg\"; fi\n\ + if [ \"$prev\" = \"--project-name\" ] || [ \"$prev\" = \"--project\" ]; then project=\"$arg\"; fi\n\ if [ \"$prev\" = \"--configuration-files\" ]; then target=\"$arg\"; fi\n\ prev=\"$arg\"\n\ done\n\ @@ -1902,7 +1902,9 @@ mod tests { assert!(result.steps.iter().any(|step| { step.source_set == "main" && matches!(step.mode, BuildMode::Full) && step.ok })); - assert!(edt_calls_text.contains("export --project-name main")); + assert!( + edt_calls_text.contains(&format!("export --project {}", base.join("main").display())) + ); assert!(designer_calls_text.contains("/LoadConfigFromFiles")); assert!(!designer_calls_text.contains("-partial")); assert!(designer_calls_text.contains( @@ -1923,12 +1925,7 @@ mod tests { .iter() .filter(|step| step.source_set == "main") .all(|step| matches!(step.mode, BuildMode::Skipped) && step.ok)); - assert_eq!( - rerun_edt_calls - .matches("export --project-name main") - .count(), - 1 - ); + assert_eq!(rerun_edt_calls.matches("export --project ").count(), 1); } #[cfg(unix)] @@ -1968,7 +1965,9 @@ mod tests { assert!(result.steps.iter().any(|step| { step.source_set == "main" && matches!(step.mode, BuildMode::Full) && step.ok })); - assert!(edt_calls_text.contains("export --project-name main")); + assert!( + edt_calls_text.contains(&format!("export --project {}", base.join("main").display())) + ); assert!(ibcmd_calls_text.contains("infobase --db-path /tmp/ib config import")); assert!(!ibcmd_calls_text.contains("config import files")); assert!(!ibcmd_calls_text.contains("--partial")); @@ -1986,7 +1985,7 @@ mod tests { #[cfg(unix)] #[test] - fn edt_build_prefers_project_name_from_dot_project_file() { + fn edt_build_uses_project_path_while_validating_dot_project_file() { let dir = tempdir().expect("tempdir"); let base = dir.path().join("base"); let work = dir.path().join("work"); @@ -2026,8 +2025,11 @@ mod tests { let edt_calls_text = fs::read_to_string(&edt_calls).expect("edt calls"); assert!(result.ok); - assert!(edt_calls_text.contains("export --project-name client_mcp")); - assert!(!edt_calls_text.contains("export --project-name client-mcp")); + assert!(edt_calls_text.contains(&format!( + "export --project {}", + base.join("exts").join("client-mcp").display() + ))); + assert!(!edt_calls_text.contains("export --project-name client_mcp")); } #[cfg(unix)] @@ -2123,7 +2125,10 @@ mod tests { assert!(!result.steps.iter().any(|step| { step.source_set == "client_mcp" && matches!(step.mode, BuildMode::Partial { .. }) })); - assert!(edt_calls_text.contains("export --project-name client_mcp")); + assert!(edt_calls_text.contains(&format!( + "export --project {}", + base.join("exts").join("client-mcp").display() + ))); assert!(designer_calls_text.contains("/LoadConfigFromFiles")); assert!(designer_calls_text.contains("-Extension client_mcp")); assert!(!designer_calls_text.contains("-partial")); @@ -2197,7 +2202,10 @@ mod tests { assert!(!result.steps.iter().any(|step| { step.source_set == "client_mcp" && matches!(step.mode, BuildMode::Partial { .. }) })); - assert!(edt_calls_text.contains("export --project-name client_mcp")); + assert!(edt_calls_text.contains(&format!( + "export --project {}", + base.join("exts").join("client-mcp").display() + ))); assert!(ibcmd_calls_text.contains("config import")); assert!(ibcmd_calls_text.contains("--extension client_mcp")); assert!(!ibcmd_calls_text.contains("config import files")); @@ -2309,7 +2317,7 @@ mod tests { assert!(result.ok); assert_eq!(edt_calls_text.matches("START").count(), 1); assert_eq!(edt_calls_text.matches("EXIT").count(), 1); - assert_eq!(edt_calls_text.matches("export --project-name").count(), 2); + assert_eq!(edt_calls_text.matches("export --project ").count(), 2); } #[cfg(unix)] @@ -2357,10 +2365,7 @@ mod tests { assert!(result.steps.iter().any(|step| { step.source_set == "main" && matches!(step.mode, BuildMode::Partial { .. }) && step.ok })); - assert_eq!( - edt_calls_text.matches("export --project-name main").count(), - 1 - ); + assert_eq!(edt_calls_text.matches("export --project ").count(), 1); assert_eq!( designer_calls_text.matches("/LoadConfigFromFiles").count(), 2 @@ -2387,7 +2392,7 @@ mod tests { let edt_calls = dir.path().join("edt-calls.log"); create_source_tree(&base); write_designer_script(&platform_script, &designer_calls, None); - write_edt_script(&edt_script, &edt_calls, Some("export --project-name")); + write_edt_script(&edt_script, &edt_calls, Some("export --project")); let config = build_edt_config(&base, &work, &dir.path().join("platform"), &edt_script); prime_edt_snapshots(&config); diff --git a/src/use_cases/build_project/coordinator.rs b/src/use_cases/build_project/coordinator.rs index 4f06468..809b417 100644 --- a/src/use_cases/build_project/coordinator.rs +++ b/src/use_cases/build_project/coordinator.rs @@ -428,7 +428,22 @@ pub(super) fn run_build_edt( let mut ibcmd_binary: Option = None; let mut edt_binary: Option = None; let mut interactive_edt = None; + let mut interactive_build_export_edt = None; let mut steps = Vec::new(); + let edt_build_export_workspace = config.work_path.join("edt-build-workspace"); + if let Err(error) = remove_storage_path(&edt_build_export_workspace) { + return Err(BuildExecutionFailure::with_payload( + AppError::Runtime(format!( + "failed to clean EDT build export workspace '{}': {error}", + edt_build_export_workspace.display() + )), + BuildResult { + ok: false, + steps, + duration_ms: started.elapsed().as_millis() as u64, + }, + )); + } for (index, source_set) in ordered_source_sets.iter().enumerate() { let Some(edt_context) = inventory.edt_context(&source_set.name).cloned() else { @@ -686,15 +701,15 @@ pub(super) fn run_build_edt( TimelineStageStatus::Running, ); let export_result = if config.tools.edt_cli.interactive_mode { - if interactive_edt.is_none() { - interactive_edt = Some( + if interactive_build_export_edt.is_none() { + interactive_build_export_edt = Some( match EdtSessionManager::for_config( config, EdtSessionHostOptions::for_cli_command(config), ) { Ok(manager) => match EdtDsl::new_shared_session( edt.clone(), - config.work_path.join("edt-workspace"), + edt_build_export_workspace.clone(), Arc::new(manager), Duration::from_millis(config.tools.edt_cli.startup_timeout_ms), Duration::from_millis(config.tools.edt_cli.command_timeout_ms), @@ -740,7 +755,9 @@ pub(super) fn run_build_edt( execute_edt_export_step( context, config, - interactive_edt.as_ref().expect("interactive edt dsl"), + interactive_build_export_edt + .as_ref() + .expect("interactive edt build export dsl"), source_set, &edt_context, &designer_context, @@ -749,7 +766,7 @@ pub(super) fn run_build_edt( } else { let one_shot_edt = EdtDsl::new( edt.clone(), - config.work_path.join("edt-workspace"), + edt_build_export_workspace.clone(), utilities.runner_for(UtilityType::EdtCli), ) .with_execution_policy( diff --git a/src/use_cases/check_syntax.rs b/src/use_cases/check_syntax.rs index 13ac47b..d895ba1 100644 --- a/src/use_cases/check_syntax.rs +++ b/src/use_cases/check_syntax.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -16,6 +17,7 @@ use crate::platform::locator::UtilityType; use crate::platform::result::PlatformCommandResult; use crate::platform::utilities::PlatformUtilities; use crate::support::error::AppError; +use crate::support::fs::clean_dir; use crate::support::temp::platform_logs_dir; #[cfg(test)] use crate::use_cases::context::CommandName; @@ -88,8 +90,18 @@ fn run_syntax_with_context( if let Some(failure) = interrupted_syntax_failure(context, "syntax", started, None) { return Err(failure); } - if let SyntaxTarget::Edt { projects } = &args.target { - return run_edt_syntax(context, config, projects, started); + if let SyntaxTarget::Edt { + projects, + exception_file, + } = &args.target + { + return run_edt_syntax( + context, + config, + projects, + exception_file.as_deref(), + started, + ); } let invocation = match normalize_invocation(args) { @@ -415,6 +427,7 @@ fn run_edt_syntax( context: &ExecutionContext, config: &AppConfig, projects: &[String], + exception_file: Option<&Path>, started: Instant, ) -> UseCaseResult { if let Some(failure) = interrupted_syntax_failure(context, "edt", started, None) { @@ -503,14 +516,58 @@ fn run_edt_syntax( )); } }; + let exceptions = match load_syntax_exceptions(exception_file) { + Ok(exceptions) => exceptions, + Err(error) => { + let app_error = AppError::Runtime(error); + let message = app_error.to_string(); + return Err(SyntaxExecutionFailure::with_payload( + app_error, + failed_result( + "edt", + SyntaxCheckStatus::ToolFailed, + -1, + started, + vec![], + None, + Some(message), + None, + ), + )); + } + }; let edt_binary = location.path; + let syntax_workspace = match prepare_edt_syntax_workspace(&config.work_path) { + Ok(workspace) => workspace, + Err(error) => { + let app_error = AppError::Runtime(format!( + "failed to prepare EDT syntax workspace '{}': {error}", + config.work_path.join("edt-syntax-workspace").display() + )); + let message = app_error.to_string(); + return Err(SyntaxExecutionFailure::with_payload( + app_error, + failed_result( + "edt", + SyntaxCheckStatus::ToolFailed, + -1, + started, + vec![], + None, + Some(message), + None, + ), + )); + } + }; let interactive_dsl = if config.tools.edt_cli.interactive_mode { - match EdtSessionManager::for_config(config, EdtSessionHostOptions::for_cli_command(config)) - { + let mut options = EdtSessionHostOptions::for_cli_command(config); + options.workspace = syntax_workspace.clone(); + match EdtSessionManager::for_config(config, options) { Ok(manager) => match EdtDsl::new_shared_session( edt_binary.clone(), - config.work_path.join("edt-workspace"), + syntax_workspace.clone(), Arc::new(manager), Duration::from_millis(config.tools.edt_cli.startup_timeout_ms), Duration::from_millis(config.tools.edt_cli.command_timeout_ms), @@ -586,7 +643,7 @@ fn run_edt_syntax( } else { EdtDsl::new( edt_binary.clone(), - config.work_path.join("edt-workspace"), + syntax_workspace.clone(), utilities.runner_for(UtilityType::EdtCli), ) .with_timeout(context.edt_timeout()) @@ -636,7 +693,13 @@ fn run_edt_syntax( .as_deref() .map(edt_validation::parse) .unwrap_or_default(); - let project_status = edt_status_from_result(result.process.exit_code, &project_issues); + let had_project_issues_before_exceptions = !project_issues.is_empty(); + let project_issues = filter_syntax_exceptions(project_issues, &exceptions); + let project_status = edt_status_from_result( + result.process.exit_code, + &project_issues, + had_project_issues_before_exceptions, + ); status = combine_status(status, project_status); if result.process.exit_code != 0 @@ -645,7 +708,10 @@ fn run_edt_syntax( exit_code = result.process.exit_code; } - if result.process.exit_code != 0 && project_issues.is_empty() { + if result.process.exit_code != 0 + && project_issues.is_empty() + && project_status == SyntaxCheckStatus::ToolFailed + { issues.push(fallback_edt_issue( &source_set.name, result.process.exit_code, @@ -755,7 +821,7 @@ fn resolve_edt_source_sets<'a>( if !unknown.is_empty() { return Err(AppError::Validation(format!( - "unknown EDT project(s): {}", + "unknown EDT source-set(s): {}", unknown.join(", ") ))); } @@ -763,9 +829,211 @@ fn resolve_edt_source_sets<'a>( Ok(selected) } -fn edt_status_from_result(exit_code: i32, issues: &[Issue]) -> SyntaxCheckStatus { +fn prepare_edt_syntax_workspace(work_path: &Path) -> std::io::Result { + let workspace = work_path.join("edt-syntax-workspace"); + clean_dir(&workspace)?; + std::fs::create_dir_all(&workspace)?; + Ok(workspace) +} + +#[derive(Default)] +struct SyntaxExceptions { + exact: HashSet, + fuzzy: Vec, +} + +impl SyntaxExceptions { + fn is_empty(&self) -> bool { + self.exact.is_empty() + } +} + +fn load_syntax_exceptions(path: Option<&Path>) -> Result { + let Some(path) = path else { + return Ok(SyntaxExceptions::default()); + }; + let content = std::fs::read_to_string(path).map_err(|error| { + format!( + "failed to read syntax exception file '{}': {error}", + path.display() + ) + })?; + + let mut exceptions = SyntaxExceptions::default(); + let mut generated_baseline = false; + for line in content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + if line.starts_with('#') { + if line.contains("Added from EDT syntax log") { + generated_baseline = true; + } + continue; + } + let normalized = normalize_exception_text(line); + if normalized.is_empty() { + continue; + } + let inserted = exceptions.exact.insert(normalized.clone()); + if let Some(signature) = syntax_exception_signature(&normalized) { + exceptions.exact.insert(signature); + } + if inserted && !generated_baseline { + exceptions.fuzzy.push(normalized); + } + } + + Ok(exceptions) +} + +fn filter_syntax_exceptions(issues: Vec, exceptions: &SyntaxExceptions) -> Vec { + if exceptions.is_empty() { + return issues; + } + + issues + .into_iter() + .filter(|issue| !is_syntax_exception(issue, exceptions)) + .collect() +} + +fn is_syntax_exception(issue: &Issue, exceptions: &SyntaxExceptions) -> bool { + let candidates = issue_exception_candidates(issue); + if candidates + .iter() + .any(|candidate| exceptions.exact.contains(candidate)) + { + return true; + } + + exceptions.fuzzy.iter().any(|exception| { + candidates.iter().any(|candidate| { + candidate.contains(exception) + || exception.contains(candidate) + || normalized_words_match(candidate, exception) + }) + }) +} + +fn issue_exception_candidates(issue: &Issue) -> Vec { + let mut candidates = Vec::new(); + match issue { + Issue::Module(issue) => { + push_exception_candidate(&mut candidates, format!("{} {}", issue.path, issue.message)); + push_exception_candidate( + &mut candidates, + format!( + "{} {} {}", + render_issue_severity(&issue.severity), + issue.path, + issue.message + ), + ); + } + Issue::Object(issue) => { + push_exception_candidate( + &mut candidates, + format!("{} {}", issue.object, issue.message), + ); + push_exception_candidate( + &mut candidates, + format!( + "{} {} {}", + render_issue_severity(&issue.severity), + issue.object, + issue.message + ), + ); + } + Issue::Edt(issue) => { + push_exception_candidate(&mut candidates, format!("{} {}", issue.path, issue.message)); + if let Some(line) = issue.line { + push_exception_candidate( + &mut candidates, + format!("{} {} {}", issue.path, line, issue.message), + ); + push_exception_candidate( + &mut candidates, + format!("{} строка {} {}", issue.path, line, issue.message), + ); + } + if let Some(check) = issue.check.as_deref() { + push_exception_candidate( + &mut candidates, + format!("{} {} {}", issue.path, check, issue.message), + ); + } + push_exception_candidate( + &mut candidates, + format!( + "{} {} {}", + render_issue_severity(&issue.severity), + issue.path, + issue.message + ), + ); + } + } + candidates +} + +fn push_exception_candidate(candidates: &mut Vec, value: String) { + let normalized = normalize_exception_text(&value); + if !normalized.is_empty() && !candidates.contains(&normalized) { + if let Some(signature) = syntax_exception_signature(&normalized) { + candidates.push(signature); + } + candidates.push(normalized); + } +} + +fn normalize_exception_text(value: &str) -> String { + let mut normalized = String::new(); + let mut previous_space = true; + for ch in value.chars().flat_map(char::to_lowercase) { + let keep = ch.is_alphanumeric() || matches!(ch, '_' | '.' | '"' | '\'' | ':' | '-' | '/'); + if keep { + normalized.push(ch); + previous_space = false; + } else if !previous_space { + normalized.push(' '); + previous_space = true; + } + } + normalized.trim().to_owned() +} + +fn normalized_words_match(candidate: &str, exception: &str) -> bool { + let mut cursor = 0usize; + let mut matched = false; + for word in exception.split_whitespace().filter(|word| word.len() > 2) { + let Some(offset) = candidate[cursor..].find(word) else { + return false; + }; + matched = true; + cursor += offset + word.len(); + } + matched +} + +fn syntax_exception_signature(normalized: &str) -> Option { + let marker = " не содержит возвращаемые типы "; + normalized + .find(marker) + .map(|index| normalized[..index + marker.trim_end().len()].to_owned()) +} + +fn edt_status_from_result( + exit_code: i32, + issues: &[Issue], + had_issues_before_exceptions: bool, +) -> SyntaxCheckStatus { if exit_code == 0 && issues.is_empty() { SyntaxCheckStatus::Clean + } else if issues.is_empty() && had_issues_before_exceptions { + SyntaxCheckStatus::Clean } else if !issues.is_empty() { SyntaxCheckStatus::IssuesFound } else { @@ -903,6 +1171,14 @@ fn issue_severity(issue: &Issue) -> &IssueSeverity { } } +fn render_issue_severity(severity: &IssueSeverity) -> &'static str { + match severity { + IssueSeverity::Error => "ERROR", + IssueSeverity::Warning => "WARNING", + IssueSeverity::Info => "INFO", + } +} + fn fallback_issue( exit_code: i32, stderr: Option<&str>, @@ -974,14 +1250,15 @@ fn fallback_edt_issue( #[cfg(test)] mod tests { use super::{ - normalize_config_flags, normalize_modules_flags, run_syntax, run_syntax_with_context, - status_from_exit_code, + filter_syntax_exceptions, normalize_config_flags, normalize_exception_text, + normalize_modules_flags, run_syntax, run_syntax_with_context, status_from_exit_code, + syntax_exception_signature, SyntaxExceptions, }; use crate::config::model::{ AppConfig, BuildConfig, BuilderBackend, SourceFormat, SourceSetConfig, SourceSetPurpose, TestsConfig, ToolsConfig, }; - use crate::domain::issue::Issue; + use crate::domain::issue::{EdtIssue, Issue, IssueSeverity}; use crate::domain::syntax::SyntaxCheckStatus; use crate::use_cases::context::{CommandName, ExecutionContext}; use crate::use_cases::request::{ @@ -992,15 +1269,23 @@ mod tests { }; use crate::use_cases::result::UseCaseErrorKind; use std::fs; + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::time::{Duration, Instant}; use tempfile::tempdir; fn make_executable(path: &Path) { - let mut perms = fs::metadata(path).expect("metadata").permissions(); - perms.set_mode(0o755); - fs::set_permissions(path, perms).expect("chmod"); + #[cfg(unix)] + { + let mut perms = fs::metadata(path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("chmod"); + } + #[cfg(not(unix))] + { + let _ = path; + } } fn write_script(path: &Path, body: &str) { @@ -1169,6 +1454,15 @@ mod tests { } } + fn edt_args(projects: Vec) -> SyntaxArgs { + SyntaxArgs { + target: SyntaxTarget::Edt { + projects, + exception_file: None, + }, + } + } + #[test] fn status_mapping_matches_designer_exit_codes() { assert_eq!(status_from_exit_code(0), SyntaxCheckStatus::Clean); @@ -1176,6 +1470,106 @@ mod tests { assert_eq!(status_from_exit_code(1), SyntaxCheckStatus::ToolFailed); } + #[test] + fn syntax_exception_filter_matches_legacy_lines() { + let issues = vec![ + Issue::Edt(EdtIssue { + path: "ОбщийМодуль.УИ_РегламентныеЗаданияСлужебный.Модуль".to_owned(), + line: None, + column: None, + message: "Возможно ошибочное свойство: \"СтандартныеПодсистемы\"".to_owned(), + severity: IssueSeverity::Warning, + check: None, + }), + Issue::Edt(EdtIssue { + path: "ОбщийМодуль.Другой.Модуль".to_owned(), + line: None, + column: None, + message: "Новая ошибка".to_owned(), + severity: IssueSeverity::Error, + check: None, + }), + ]; + let mut exceptions = SyntaxExceptions::default(); + let exception = normalize_exception_text( + "УниверсальныеИнструменты ОбщийМодуль.УИ_РегламентныеЗаданияСлужебный.Модуль Возможно ошибочное свойство: \"СтандартныеПодсистемы\"", + ); + exceptions.exact.insert(exception.clone()); + exceptions.fuzzy.push(exception); + + let filtered = filter_syntax_exceptions(issues, &exceptions); + + assert_eq!(filtered.len(), 1); + match &filtered[0] { + Issue::Edt(issue) => assert_eq!(issue.path, "ОбщийМодуль.Другой.Модуль"), + _ => panic!("expected edt issue"), + } + } + + #[test] + fn syntax_exception_filter_matches_line_scoped_edt_lines() { + let issues = vec![ + Issue::Edt(EdtIssue { + path: "ОбщийМодуль.КалендарныеГрафики.Модуль".to_owned(), + line: Some(1357), + column: None, + message: "Возможно Поле указано в описании".to_owned(), + severity: IssueSeverity::Info, + check: Some( + "com.e1c.v8codestyle.bsl:doc-comment-field-in-description-suggestion" + .to_owned(), + ), + }), + Issue::Edt(EdtIssue { + path: "ОбщийМодуль.КалендарныеГрафики.Модуль".to_owned(), + line: Some(1358), + column: None, + message: "Возможно Поле указано в описании".to_owned(), + severity: IssueSeverity::Info, + check: Some( + "com.e1c.v8codestyle.bsl:doc-comment-field-in-description-suggestion" + .to_owned(), + ), + }), + ]; + let mut exceptions = SyntaxExceptions::default(); + exceptions.exact.insert(normalize_exception_text( + "ОбщийМодуль.КалендарныеГрафики.Модуль строка 1357 Возможно Поле указано в описании", + )); + + let filtered = filter_syntax_exceptions(issues, &exceptions); + + assert_eq!(filtered.len(), 1); + match &filtered[0] { + Issue::Edt(issue) => assert_eq!(issue.line, Some(1358)), + _ => panic!("expected edt issue"), + } + } + + #[test] + fn syntax_exception_filter_matches_return_type_order_changes() { + let issues = vec![Issue::Edt(EdtIssue { + path: "ОбщийМодуль.Модуль.Модуль".to_owned(), + line: Some(10), + column: None, + message: "Декларируемое свойство \"Значение\" с типом: \"Строка\" не содержит возвращаемые типы \"ТипБ, ТипА\"".to_owned(), + severity: IssueSeverity::Error, + check: Some("com.e1c.v8codestyle.bsl:constructor-function-return-section".to_owned()), + })]; + let mut exceptions = SyntaxExceptions::default(); + let exception = normalize_exception_text( + "ОбщийМодуль.Модуль.Модуль строка 10 Декларируемое свойство \"Значение\" с типом: \"Строка\" не содержит возвращаемые типы \"ТипА, ТипБ\"", + ); + exceptions.exact.insert(exception.clone()); + exceptions + .exact + .insert(syntax_exception_signature(&exception).expect("signature")); + + let filtered = filter_syntax_exceptions(issues, &exceptions); + + assert!(filtered.is_empty()); + } + #[test] fn normalizes_config_flags() { let args = DesignerConfigSyntaxArgs::new( @@ -1369,9 +1763,7 @@ mod tests { 1, ); let config = sample_edt_config(&base, &work, &binary); - let args = SyntaxArgs { - target: SyntaxTarget::Edt { projects: vec![] }, - }; + let args = edt_args(vec![]); let failure = run_syntax(&config, &args).expect_err("expected issues"); let result = failure @@ -1385,7 +1777,35 @@ mod tests { } #[test] - fn syntax_edt_rejects_unknown_project_names() { + fn syntax_edt_uses_clean_syntax_workspace_instead_of_build_workspace() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let main_dir = base.join("main-edt"); + let ext_dir = base.join("ext-edt"); + let binary = dir.path().join("edt").join("1cedtcli"); + let calls_log = dir.path().join("edt.calls.log"); + let stale_marker = work.join("edt-syntax-workspace").join("stale.marker"); + fs::create_dir_all(&work).expect("work"); + fs::create_dir_all(&main_dir).expect("main"); + fs::create_dir_all(&ext_dir).expect("ext"); + fs::create_dir_all(stale_marker.parent().expect("stale parent")).expect("syntax ws"); + fs::write(&stale_marker, "stale").expect("stale marker"); + write_edt_script_with_calls(&binary, &calls_log); + let config = sample_edt_config(&base, &work, &binary); + let args = edt_args(vec!["main".to_owned()]); + + let result = run_syntax(&config, &args).expect("clean run"); + + assert_eq!(result.status, SyntaxCheckStatus::Clean); + assert!(!stale_marker.exists()); + let calls = fs::read_to_string(&calls_log).expect("calls"); + assert!(calls.contains("edt-syntax-workspace")); + assert!(!calls.contains("edt-workspace")); + } + + #[test] + fn syntax_edt_rejects_unknown_source_set_names() { let dir = tempdir().expect("tempdir"); let base = dir.path().join("base"); let work = dir.path().join("work"); @@ -1397,11 +1817,7 @@ mod tests { fs::create_dir_all(&ext_dir).expect("ext"); write_edt_script(&binary, None, None, 0); let config = sample_edt_config(&base, &work, &binary); - let args = SyntaxArgs { - target: SyntaxTarget::Edt { - projects: vec!["unknown".to_owned()], - }, - }; + let args = edt_args(vec!["unknown".to_owned()]); let failure = run_syntax(&config, &args).expect_err("expected validation failure"); @@ -1409,7 +1825,7 @@ mod tests { assert!(failure .error .to_string() - .contains("unknown EDT project(s): unknown")); + .contains("unknown EDT source-set(s): unknown")); } #[test] @@ -1428,9 +1844,7 @@ mod tests { "out=\"\"\nargs=\"$*\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--file\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif printf '%s' \"$args\" | grep -q -- 'main-edt'; then\n if [ -n \"$out\" ]; then printf 'ERROR\\tCatalogs.Items\\t1\\t1\\tRule\\tmsg\\n' > \"$out\"; fi\n exit 1\nfi\nexit 17", ); let config = sample_edt_config(&base, &work, &binary); - let args = SyntaxArgs { - target: SyntaxTarget::Edt { projects: vec![] }, - }; + let args = edt_args(vec![]); let failure = run_syntax(&config, &args).expect_err("expected failure"); let result = failure @@ -1455,11 +1869,7 @@ mod tests { write_script(&binary, "sleep 1\nexit 0"); let mut config = sample_edt_config(&base, &work, &binary); config.tools.edt_cli.command_timeout_ms = 20; - let args = SyntaxArgs { - target: SyntaxTarget::Edt { - projects: vec!["main".to_owned()], - }, - }; + let args = edt_args(vec!["main".to_owned()]); let context = ExecutionContext::mcp_stdio(CommandName::Syntax) .with_edt_timeout(Some(Duration::from_millis(20))); @@ -1488,9 +1898,7 @@ mod tests { fs::create_dir_all(&ext_dir).expect("ext"); write_script(&binary, "sleep 0.06\nexit 0"); let config = sample_edt_config(&base, &work, &binary); - let args = SyntaxArgs { - target: SyntaxTarget::Edt { projects: vec![] }, - }; + let args = edt_args(vec![]); let context = ExecutionContext::mcp_stdio(CommandName::Syntax) .with_deadline(Some(Instant::now() + Duration::from_millis(80))); @@ -1521,11 +1929,7 @@ mod tests { write_edt_script_with_calls(&binary, &calls_log); let mut config = sample_edt_config(&base, &work, &binary); config.tools.edt_cli.interactive_mode = false; - let args = SyntaxArgs { - target: SyntaxTarget::Edt { - projects: vec!["main".to_owned()], - }, - }; + let args = edt_args(vec!["main".to_owned()]); let result = run_syntax(&config, &args).expect("syntax"); let calls = fs::read_to_string(&calls_log).expect("calls log"); @@ -1551,11 +1955,7 @@ mod tests { write_interactive_edt_script_with_calls(&binary, &calls_log); let mut config = sample_edt_config(&base, &work, &binary); config.tools.edt_cli.interactive_mode = true; - let args = SyntaxArgs { - target: SyntaxTarget::Edt { - projects: vec!["main".to_owned()], - }, - }; + let args = edt_args(vec!["main".to_owned()]); let result = run_syntax(&config, &args).expect("syntax"); let calls = fs::read_to_string(&calls_log).expect("calls log"); diff --git a/src/use_cases/config_init.rs b/src/use_cases/config_init.rs index 3ddcd3d..15175a5 100644 --- a/src/use_cases/config_init.rs +++ b/src/use_cases/config_init.rs @@ -7,7 +7,7 @@ use crate::config::schema::main_config_schema_url; use crate::domain::config_init::{ConfigInitResult, ConfigInitSourceSet}; use crate::support::edt_project::{self, EdtProjectKind}; use crate::support::error::AppError; -use crate::support::path::is_safe_path_segment; +use crate::support::path::{is_safe_path_segment, strip_windows_verbatim_prefix}; use crate::support::source_descriptor::{ self, SourceDescriptorParseError, SourceDescriptorPurpose, SourceSetRootScanError, }; @@ -100,9 +100,9 @@ pub fn execute(request: &ConfigInitRequest) -> Result String { .ok() .filter(|relative| !relative.as_os_str().is_empty()) { - return relative.display().to_string(); + return logical_path(relative); } let root_components = normalized_components(root); @@ -813,10 +813,21 @@ fn relative_path(root: &Path, path: &Path) -> String { if relative.as_os_str().is_empty() { ".".to_owned() } else { - relative.display().to_string() + logical_path(&relative) } } +fn display_path(path: &Path) -> String { + strip_windows_verbatim_prefix(&path.display().to_string()) +} + +fn logical_path(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + fn normalized_components(path: &Path) -> Vec { let mut components = Vec::new(); for component in path.components() { @@ -850,7 +861,8 @@ fn render_config( )); yaml.push_str("# Generated by v8-runner config init\n"); yaml.push_str("workPath: 'build'\n"); - yaml.push_str("execution_timeout: 300000\n"); + yaml.push_str("# Maximum execution time for a v8-runner command, in milliseconds.\n"); + yaml.push_str("execution_timeout: 3600000\n"); yaml.push_str(&format!("format: {}\n", format.as_yaml())); yaml.push_str(&format!("builder: {}\n", builder.as_yaml())); yaml.push_str("infobase:\n"); @@ -973,8 +985,8 @@ fn escape_yaml(value: &str) -> String { #[cfg(test)] mod tests { use super::{ - discover_sources, execute, ConfigBuilderRequest, ConfigFormatRequest, ConfigInitRequest, - SourcePurpose, + discover_sources, execute, render_config, ConfigBuilderRequest, ConfigFormatRequest, + ConfigInitRequest, SourcePurpose, }; use crate::config::loader::load_config; use std::path::Path; @@ -1099,12 +1111,27 @@ mod tests { && source.path == "extensions/sales" && source.source_type == "EXTENSION" })); - assert!(std::fs::read_to_string(dir.path().join("v8project.yaml")) - .expect("config") - .contains("name: 'SalesAddon'")); - assert!(std::fs::read_to_string(dir.path().join("v8project.yaml")) - .expect("config") - .contains("type: EXTENSION")); + let config_text = + std::fs::read_to_string(dir.path().join("v8project.yaml")).expect("config"); + assert!(config_text + .contains("# Maximum execution time for a v8-runner command, in milliseconds.")); + assert!(config_text.contains("execution_timeout: 3600000")); + assert!(config_text.contains("name: 'SalesAddon'")); + assert!(config_text.contains("type: EXTENSION")); + } + + #[test] + fn render_config_writes_one_hour_global_execution_timeout_with_comment() { + let yaml = render_config( + None, + ConfigFormatRequest::Designer, + ConfigBuilderRequest::Designer, + &[], + None, + ); + + assert!(yaml.contains("# Maximum execution time for a v8-runner command, in milliseconds.")); + assert!(yaml.contains("execution_timeout: 3600000")); } #[test] diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index 5f0e0c3..577489d 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -427,7 +427,7 @@ mod tests { .contains("v8-runner build")); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("ENTERPRISE")); - assert!(args.contains("/C\"runMcp;mcpPort=9874\"")); + assert!(args.contains("/C\nrunMcp;mcpPort=9874\n")); assert!(!args.contains("/LoadCfg")); assert!(!args.contains("-Extension")); } diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index 2a8d812..42cea14 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -191,9 +191,10 @@ pub struct SyntaxRequest { pub enum SyntaxTargetRequest { DesignerConfig(DesignerConfigSyntaxRequest), DesignerModules(DesignerModulesSyntaxRequest), - /// Runs EDT validation for selected projects or all EDT projects when empty. + /// Runs EDT validation for selected source-sets or all EDT source-sets when empty. Edt { projects: Vec, + exception_file: Option, }, } diff --git a/src/use_cases/tools_download.rs b/src/use_cases/tools_download.rs index 1135e8f..67383b4 100644 --- a/src/use_cases/tools_download.rs +++ b/src/use_cases/tools_download.rs @@ -31,6 +31,9 @@ const CLIENT_MCP_REPO: &str = "1c-neurofish/onec-client-mcp-devkit"; const YAXUNIT_SOURCE_PREFIX: &str = "exts/yaxunit/"; const CLIENT_MCP_SOURCE_PREFIX: &str = "exts/client-mcp/"; const DOWNLOAD_MARKER_FILE: &str = ".v8-runner-tools-download.json"; +const DEFAULT_VANESSA_VA_FIELDS: &str = " va:\n params_path: 'tools\\VAParams.json'\n profile: 'all'\n timeouts:\n total_ms: 3600000\n profiles:\n all:\n feature_path: 'features'\n ignore_tags:\n - 'IgnoreOnCIMainBuild'\n"; +const DEFAULT_VANESSA_TESTS_FIELDS: &str = " execution_timeout_seconds: 3600\n va:\n params_path: 'tools\\VAParams.json'\n profile: 'all'\n timeouts:\n total_ms: 3600000\n profiles:\n all:\n feature_path: 'features'\n ignore_tags:\n - 'IgnoreOnCIMainBuild'\n"; +const DEFAULT_VANESSA_TESTS_CONFIG: &str = "tests:\n execution_timeout_seconds: 3600\n va:\n params_path: 'tools\\VAParams.json'\n profile: 'all'\n timeouts:\n total_ms: 3600000\n profiles:\n all:\n feature_path: 'features'\n ignore_tags:\n - 'IgnoreOnCIMainBuild'\n"; pub fn execute( context: &ExecutionContext, @@ -662,6 +665,7 @@ fn update_config_for_download( } ToolDownloadTarget::Yaxunit => {} ToolDownloadTarget::VanessaAutomationSingle => { + add_default_vanessa_tests_config(context, config_path)?; let local_overlay = render_vanessa_local_overlay(local_config_path, destinations)?; publish_bytes(context, local_overlay.as_bytes(), local_config_path)?; } @@ -767,6 +771,123 @@ fn insert_yaxunit_source_set_text(content: &str) -> Result { Ok(rendered) } +fn add_default_vanessa_tests_config( + context: &ExecutionContext, + config_path: &Path, +) -> Result<(), AppError> { + let content = fs::read_to_string(config_path).map_err(io_error("failed to read config"))?; + let root: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|error| AppError::Runtime(format!("failed to parse config YAML: {error}")))?; + let mapping = root + .as_mapping() + .ok_or_else(|| AppError::Validation("expected a YAML mapping at config root".to_owned()))?; + let tests_key = serde_yaml::Value::String("tests".to_owned()); + if let Some(tests) = mapping.get(&tests_key) { + let tests_mapping = tests.as_mapping().ok_or_else(|| { + AppError::Validation( + "config tests section must use block mapping style before tools download can update it" + .to_owned(), + ) + })?; + let has_execution_timeout = tests_mapping.contains_key(serde_yaml::Value::String( + "execution_timeout_seconds".to_owned(), + )); + let has_va = tests_mapping.contains_key(serde_yaml::Value::String("va".to_owned())); + if has_execution_timeout && has_va { + return Ok(()); + } + let rendered = + insert_missing_vanessa_tests_fields_text(&content, has_execution_timeout, has_va)?; + return publish_bytes(context, rendered.as_bytes(), config_path); + } + + let rendered = insert_top_level_vanessa_tests_config_text(&content)?; + publish_bytes(context, rendered.as_bytes(), config_path) +} + +fn insert_top_level_vanessa_tests_config_text(content: &str) -> Result { + let insertion_offset = find_top_level_insertion_offset(content, "source-set:") + .or_else(|| find_top_level_insertion_offset(content, "tools:")) + .unwrap_or(content.len()); + let mut rendered = String::with_capacity(content.len() + DEFAULT_VANESSA_TESTS_CONFIG.len()); + rendered.push_str(&content[..insertion_offset]); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered.push_str(DEFAULT_VANESSA_TESTS_CONFIG); + rendered.push_str(&content[insertion_offset..]); + Ok(rendered) +} + +fn insert_missing_vanessa_tests_fields_text( + content: &str, + has_execution_timeout: bool, + has_va: bool, +) -> Result { + let tests_start = content + .lines() + .position(|line| line.trim_end() == "tests:") + .ok_or_else(|| { + AppError::Validation( + "config tests section must use block style before tools download can update it" + .to_owned(), + ) + })?; + + let mut insertion_offset = content.len(); + let mut offset = 0usize; + for (index, line) in content.split_inclusive('\n').enumerate() { + let line_start = offset; + offset += line.len(); + if index <= tests_start { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let is_top_level = line + .chars() + .next() + .is_some_and(|first| !first.is_whitespace()); + if is_top_level { + insertion_offset = line_start; + break; + } + } + + let mut rendered = String::with_capacity(content.len() + DEFAULT_VANESSA_TESTS_FIELDS.len()); + rendered.push_str(&content[..insertion_offset]); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + if !has_execution_timeout { + rendered.push_str(" execution_timeout_seconds: 3600\n"); + } + if !has_va { + rendered.push_str(DEFAULT_VANESSA_VA_FIELDS); + } + rendered.push_str(&content[insertion_offset..]); + Ok(rendered) +} + +fn find_top_level_insertion_offset(content: &str, marker: &str) -> Option { + let mut offset = 0usize; + for line in content.split_inclusive('\n') { + let line_start = offset; + offset += line.len(); + if line.trim_end() == marker + && line + .chars() + .next() + .is_some_and(|first| !first.is_whitespace()) + { + return Some(line_start); + } + } + None +} + fn render_vanessa_local_overlay( path: &Path, destinations: &[ToolDownloadDestination], @@ -1068,4 +1189,31 @@ mod tests { "http://127.0.0.1:1234/archive.zip" ); } + + #[test] + fn inserts_default_vanessa_tests_config_before_source_sets() { + let content = "workPath: build\nformat: DESIGNER\nsource-set:\n - name: main\n"; + + let rendered = insert_top_level_vanessa_tests_config_text(content).expect("render config"); + + assert!(rendered.contains( + "tests:\n execution_timeout_seconds: 3600\n va:\n params_path: 'tools\\VAParams.json'\n profile: 'all'\n" + )); + assert!(rendered.contains(" total_ms: 3600000\n")); + assert!(rendered.contains(" feature_path: 'features'\n")); + assert!(rendered.contains(" - 'IgnoreOnCIMainBuild'\nsource-set:")); + } + + #[test] + fn inserts_missing_vanessa_fields_without_replacing_existing_tests() { + let content = "workPath: build\ntests:\n yaxunit:\n report_path: build/report.xml\nsource-set:\n - name: main\n"; + + let rendered = + insert_missing_vanessa_tests_fields_text(content, false, false).expect("render config"); + + assert!(rendered.contains(" yaxunit:\n report_path: build/report.xml\n")); + assert!(rendered.contains(" execution_timeout_seconds: 3600\n va:\n")); + assert!(rendered.contains(" profile: 'all'\n")); + assert!(rendered.contains("source-set:\n - name: main\n")); + } } diff --git a/src/use_cases/vanessa.rs b/src/use_cases/vanessa.rs index db6ce1e..5ddec7d 100644 --- a/src/use_cases/vanessa.rs +++ b/src/use_cases/vanessa.rs @@ -226,10 +226,12 @@ fn apply_test_overlay(object: &mut Map, artifacts: VanessaTestArt Value::Bool(true), ); object.insert("ДелатьОтчетВФорматеjUnit".to_owned(), Value::Bool(true)); + let junit_dir = Value::String(artifacts.junit_dir.display().to_string()); object.insert( "КаталогВыгрузкиJUnit".to_owned(), - Value::String(artifacts.junit_dir.display().to_string()), + junit_dir.clone(), ); + ensure_object(object, "ОтчетJUnit").insert("КаталогВыгрузкиJUnit".to_owned(), junit_dir); apply_logging_overlay( object, artifacts.runner_log, @@ -237,6 +239,16 @@ fn apply_test_overlay(object: &mut Map, artifacts: VanessaTestArt ); } +fn ensure_object<'a>(object: &'a mut Map, key: &str) -> &'a mut Map { + if !object.get(key).is_some_and(Value::is_object) { + object.insert(key.to_owned(), Value::Object(Map::new())); + } + object + .get_mut(key) + .and_then(Value::as_object_mut) + .expect("object value was just inserted") +} + fn apply_logging_overlay( object: &mut Map, text_log_path: &Path, diff --git a/tests/cli_build.rs b/tests/cli_build.rs index c0409f8..fd559ec 100644 --- a/tests/cli_build.rs +++ b/tests/cli_build.rs @@ -676,7 +676,7 @@ fn build_edt_text_interleaves_export_stage_after_edt_log() { let ibcmd_calls = fs::read_to_string(ibcmd_calls_log).expect("ibcmd calls"); let edt_calls = fs::read_to_string(edt_calls_log).expect("edt calls"); - assert!(edt_calls.contains("export --project-name configuration")); + assert!(edt_calls.contains("export --project ")); assert!(ibcmd_calls.contains("config import")); assert!(ibcmd_calls.contains("config apply")); } diff --git a/tests/cli_config_init.rs b/tests/cli_config_init.rs index eda20c1..c655414 100644 --- a/tests/cli_config_init.rs +++ b/tests/cli_config_init.rs @@ -91,6 +91,8 @@ fn config_init_creates_yaml_with_detected_designer_sources() { assert!(config.contains("format: DESIGNER")); assert!(!config.contains("basePath:")); assert!(config.contains("workPath: 'build'")); + assert!(config.contains("# Maximum execution time for a v8-runner command, in milliseconds.")); + assert!(config.contains("execution_timeout: 3600000")); assert!(config.contains("infobase:")); assert!(config.contains(" connection: 'File=build/ib'")); assert!(config.contains("path: 'src/configuration'")); diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index 7bd4647..9593222 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -359,7 +359,7 @@ fn launch_ordinary_supports_typed_keys_and_filters_reserved_raw_duplicates() { assert!(args.contains("/UsePrivilegedMode")); assert!(args.contains("/Execute")); assert!(args.contains("/tmp/tool.epf")); - assert!(args.contains("/C\"DoWork\"")); + assert!(args.contains("/C\nDoWork\n")); assert!(args.contains("DoWork")); assert!(args.contains("/WA-")); assert!(args.contains("/tmp/user.out.log")); @@ -407,7 +407,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { assert!(args.contains("/RunModeOrdinaryApplication")); assert!(args.contains("/Execute")); assert!(args.contains("vanessa-automation.epf")); - assert!(args.contains("/C\"runMcp=/tmp/mcp conf.json;mcpPort=9874;VAParams=")); + assert!(args.contains("/C\nrunMcp=/tmp/mcp conf.json;mcpPort=9874;VAParams=")); assert!(!args.contains("StartFeaturePlayer")); assert!(args.contains("/TESTMANAGER")); assert!(args.contains("/WA-")); diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 823da66..b86873a 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -773,6 +773,10 @@ fn test_va_builds_vanessa_command_and_overlay() { .as_str() .expect("КаталогВыгрузкиJUnit") .contains("/junit")); + assert!(params["ОтчетJUnit"]["КаталогВыгрузкиJUnit"] + .as_str() + .expect("ОтчетJUnit.КаталогВыгрузкиJUnit") + .contains("/junit")); assert_eq!(params["ДелатьЛогВыполненияСценариевВТекстовыйФайл"], true); assert_eq!(params["ВыводитьВЛогВыполнениеШагов"], true); assert_eq!(params["ПодробныйЛогВыполненияСценариев"], 1); @@ -1095,7 +1099,10 @@ fn test_module_edt_extension_build_uses_full_load_before_enterprise_launch() { assert!(build_calls_text.contains("-Extension client_mcp")); assert!(!build_calls_text.contains("-partial")); - assert!(edt_calls_text.contains("export --project-name client_mcp")); + assert!(edt_calls_text.contains(&format!( + "export --project {}", + base_path.join("exts").join("client-mcp").display() + ))); assert!(test_calls_text.contains("RunUnitTests=")); assert_eq!(payload["ok"], true); assert_eq!( diff --git a/tests/cli_tools_download.rs b/tests/cli_tools_download.rs index 5cc724e..ca5e854 100644 --- a/tests/cli_tools_download.rs +++ b/tests/cli_tools_download.rs @@ -313,6 +313,12 @@ fn tools_download_sources_writes_source_set_and_local_tool_settings() { assert!(config.contains("name: tests")); assert!(config.contains("type: EXTENSION")); assert!(config.contains("path: tests")); + assert!(config.contains("tests:\n execution_timeout_seconds: 3600\n va:")); + assert!(config.contains(" params_path: 'tools\\VAParams.json'")); + assert!(config.contains(" profile: 'all'")); + assert!(config.contains(" total_ms: 3600000")); + assert!(config.contains(" feature_path: 'features'")); + assert!(config.contains(" - 'IgnoreOnCIMainBuild'")); assert!(dir .path() .join("tests/src/Configuration/Configuration.mdo")