Skip to main content

kcl_lib/
test_server.rs

1//! Types used to send data to the test server.
2
3use std::path::PathBuf;
4
5use kittycad_modeling_cmds::websocket::RawFile;
6#[cfg(test)]
7use serde::Deserialize;
8
9use crate::ConnectionError;
10use crate::ExecError;
11use crate::ExecOutcome;
12use crate::KclError;
13use crate::KclErrorWithOutputs;
14use crate::Program;
15use crate::engine::new_zoo_client;
16use crate::errors::ExecErrorWithState;
17use crate::execution::EnvironmentRef;
18use crate::execution::ExecState;
19use crate::execution::ExecutorContext;
20use crate::execution::ExecutorSettings;
21
22#[derive(serde::Deserialize, serde::Serialize)]
23pub struct RequestBody {
24    pub kcl_program: String,
25    #[serde(default)]
26    pub test_name: String,
27}
28
29/// Executes a KCL program. Only returns success or error.
30pub async fn execute(code: &str, current_file: Option<PathBuf>) -> Result<(), ExecError> {
31    let program = Program::parse_no_errs(code).map_err(KclErrorWithOutputs::no_outputs)?;
32    let version = program.language_version().map_err(KclErrorWithOutputs::no_outputs)?;
33    let ctx = new_context(true, current_file, true, version).await?;
34    let res = do_execute(&ctx, program, None)
35        .await
36        .map(|_| ())
37        .map_err(|err| err.error);
38    ctx.close().await;
39    res
40}
41
42/// Executes a KCL program in geometry-only mode and returns its execution results.
43/// Closes the engine connection before returning, including when execution fails.
44pub async fn execute_with_exec_outcome(code: &str, current_file: Option<PathBuf>) -> Result<ExecOutcome, ExecError> {
45    let program = Program::parse_no_errs(code).map_err(KclErrorWithOutputs::no_outputs)?;
46    let version = program.language_version().map_err(KclErrorWithOutputs::no_outputs)?;
47    let ctx = new_context(true, current_file, true, version).await?;
48    let result = async {
49        let (state, environment) = do_execute(&ctx, program, None).await.map_err(|err| err.error)?;
50        state
51            .into_exec_outcome(environment, &ctx)
52            .await
53            .map_err(KclErrorWithOutputs::no_outputs)
54            .map_err(ExecError::from)
55    }
56    .await;
57    ctx.close().await;
58    result
59}
60
61#[cfg(test)]
62pub struct Snapshot3d {
63    /// Bytes of the snapshot.
64    pub image: image::DynamicImage,
65    /// Glb binary containing mesh and brep data
66    pub glb: Glb,
67}
68
69/// Execute the kcl and ask the engine to render an image
70/// 2d kcl files can't be exported for local render
71/// Fails if geometry_only = true
72/// CTX should be closed by caller.
73async fn execute_locally_and_render_on_engine(
74    ctx: &ExecutorContext,
75    program: Program,
76    deprecation_version_override: Option<&str>,
77) -> Result<(ExecState, EnvironmentRef, image::DynamicImage), ExecErrorWithState> {
78    let (exec_state, env_ref) = do_execute(ctx, program, deprecation_version_override).await?;
79    let snapshot_png_bytes = ctx
80        .prepare_snapshot()
81        .await
82        .map_err(|err| ExecErrorWithState::new(err, exec_state.clone(), None))?
83        .contents
84        .0;
85
86    // Decode the snapshot, return it.
87    let img = image::ImageReader::new(std::io::Cursor::new(snapshot_png_bytes))
88        .with_guessed_format()
89        .map_err(|e| ExecError::BadPng(e.to_string()))
90        .and_then(|x| x.decode().map_err(|e| ExecError::BadPng(e.to_string())))
91        .map_err(|err| ExecErrorWithState::new(err, exec_state.clone(), None))?;
92
93    Ok((exec_state, env_ref, img))
94}
95
96/// Execute the kcl then export the resulting glb and CPU render an image locally
97/// cheaper than engine render since we can use the engine in geometry-only mode.
98/// CTX should be closed by caller.
99#[cfg(test)]
100async fn execute_export_and_render_locally(
101    ctx: &ExecutorContext,
102    program: Program,
103    deprecation_version_override: Option<&str>,
104) -> Result<(ExecState, EnvironmentRef, Snapshot3d), ExecErrorWithState> {
105    let (exec_state, env_ref) = do_execute(ctx, program, deprecation_version_override).await?;
106
107    // export glb
108    let glb_blob_files = match ctx
109        .export(kittycad_modeling_cmds::format::OutputFormat3d::Gltf(
110            kittycad_modeling_cmds::format::gltf::export::Options::builder()
111                .storage(kittycad_modeling_cmds::format::gltf::export::Storage::Binary)
112                .build(),
113        ))
114        .await
115    {
116        Ok(f) => f,
117        Err(err) => {
118            return Err(ExecErrorWithState::new(
119                ExecError::BadExport(format!("Export failed: {err:?}")),
120                exec_state.clone(),
121                None,
122            ));
123        }
124    };
125    if glb_blob_files.len() != 1 {
126        return Err(ExecErrorWithState::new(
127            ExecError::BadExport(format!("Expected 1 glb file, found {}", glb_blob_files.len())),
128            exec_state,
129            None,
130        ));
131    }
132    let glb: Glb = glb_blob_files
133        .into_iter()
134        .next()
135        .unwrap_or_else(|| RawFile {
136            name: String::new(),
137            contents: vec![],
138        })
139        .into();
140    let image = glb_render::render(&glb.bytes)
141        .map_err(|e| ExecErrorWithState::new(ExecError::BadExport(e), exec_state.clone(), None))?;
142
143    let snap_3d = Snapshot3d { image, glb };
144    Ok((exec_state, env_ref, snap_3d))
145}
146
147/// single-file binary blob containing mesh and brep
148pub struct Glb {
149    pub name: String,
150    pub bytes: Vec<u8>,
151}
152
153impl From<RawFile> for Glb {
154    fn from(value: RawFile) -> Self {
155        Glb {
156            name: value.name,
157            bytes: value.contents,
158        }
159    }
160}
161
162#[cfg(test)]
163pub enum TestGraphicsArtifact {
164    Image(image::DynamicImage),
165    ImageAndGlb { image: image::DynamicImage, glb: Glb },
166    None,
167}
168
169#[cfg(test)]
170impl TestGraphicsArtifact {
171    pub fn image(self) -> Option<image::DynamicImage> {
172        match self {
173            Self::Image(img) => Some(img),
174            Self::ImageAndGlb { image, .. } => Some(image),
175            Self::None => None,
176        }
177    }
178}
179
180#[cfg(test)]
181#[derive(Deserialize, Debug, Clone, Default)]
182pub enum TestGraphicsParams {
183    /// use the 3d engine scene to render an image
184    EngineRender { reason: String },
185    /// the model is exportable. export and CPU render
186    #[default]
187    ExportAndRender,
188    /// the model doesn't need any graphical test output
189    None,
190}
191
192#[cfg(test)]
193impl TestGraphicsParams {
194    fn geometry_only(&self) -> bool {
195        matches!(self, Self::ExportAndRender | Self::None)
196    }
197    /// kcl tests have `no3d` or `norun` flags in their declaration.
198    /// `norun` means "no graphics" and "no3d" means we want graphics but the model can't yet be exported for local rendering.
199    /// Translate these requirements into a more descriptive type here.
200    fn from_kcl_sample_spec(no_3d: bool, no_run: bool) -> Self {
201        match (no_3d, no_run) {
202            (true, false) => Self::EngineRender {
203                // It would be nice for the kcl sample itself to contain richer information about why it's marked no3d.
204                // But this is the best info we have for now.
205                reason: "KCL sample marked 'no3d'".to_string(),
206            },
207            (false, false) => Self::ExportAndRender,
208            (true, true) | (false, true) => Self::None,
209        }
210    }
211}
212
213#[cfg(test)]
214async fn execute_from_graphics_params(
215    graphics: TestGraphicsParams,
216    program: Program,
217    deprecation_version_override: Option<&str>,
218    ctx: &ExecutorContext,
219) -> Result<(ExecState, EnvironmentRef, TestGraphicsArtifact), ExecErrorWithState> {
220    match graphics {
221        // maybe there's something we can do to pipe reason into test output,
222        // or maybe the main importance of the field is just that it must exist in config.toml files
223        TestGraphicsParams::EngineRender { reason: _r } => {
224            execute_locally_and_render_on_engine(ctx, program, deprecation_version_override)
225                .await
226                .map(|(state, env, image)| (state, env, TestGraphicsArtifact::Image(image)))
227        }
228        TestGraphicsParams::ExportAndRender => {
229            execute_export_and_render_locally(ctx, program, deprecation_version_override)
230                .await
231                .map(|(state, env, snap_3d)| {
232                    (
233                        state,
234                        env,
235                        TestGraphicsArtifact::ImageAndGlb {
236                            image: snap_3d.image,
237                            glb: snap_3d.glb,
238                        },
239                    )
240                })
241        }
242        TestGraphicsParams::None => do_execute(ctx, program, deprecation_version_override)
243            .await
244            .map(|(state, env)| (state, env, TestGraphicsArtifact::None)),
245    }
246}
247
248#[cfg(test)]
249pub async fn kcl_doc_execute_and_snapshot(
250    code: &str,
251    current_file: Option<PathBuf>,
252    no_3d: bool,
253    no_run: bool,
254) -> Result<TestGraphicsArtifact, ExecError> {
255    let graphics = TestGraphicsParams::from_kcl_sample_spec(no_3d, no_run);
256    let program = Program::parse_no_errs(code).map_err(KclErrorWithOutputs::no_outputs)?;
257    let version = program.language_version().map_err(KclErrorWithOutputs::no_outputs)?;
258    let ctx = new_context(true, current_file, graphics.geometry_only(), version).await?;
259
260    let result: Result<TestGraphicsArtifact, ExecError> = execute_from_graphics_params(graphics, program, None, &ctx)
261        .await
262        .map(|(_exec_state, _env, graphics)| graphics)
263        .map_err(|e| e.error);
264
265    ctx.close().await;
266    result
267}
268
269/// Executes a KCL program and takes a snapshot without closing the engine
270/// connection. If OK, the caller must close the returned context.
271/// If Err, the context will already be closed within this function.
272#[cfg(test)]
273pub async fn execute_sim_test_no_close(
274    ast: Program,
275    current_file: Option<PathBuf>,
276    deprecation_version_override: Option<&str>,
277    graphics: TestGraphicsParams,
278) -> Result<(ExecState, ExecutorContext, EnvironmentRef, TestGraphicsArtifact), ExecErrorWithState> {
279    let heartbeats = Some(5);
280    let version = ast
281        .language_version()
282        .map_err(|error| {
283            let mut error = KclErrorWithOutputs::no_outputs(error);
284            if let Some(path) = &current_file {
285                error.filenames.insert(
286                    crate::ModuleId::default(),
287                    crate::modules::ModulePath::Local {
288                        value: crate::TypedPath(path.clone()),
289                        original_import_path: None,
290                    },
291                );
292            }
293            error
294        })
295        .map_err(ExecError::from)?;
296    let ctx = new_context_with_heartbeats(true, current_file, heartbeats, graphics.geometry_only(), version).await?;
297    let result = execute_from_graphics_params(graphics, ast, deprecation_version_override, &ctx).await;
298    // we shouldn't let the ctx leave this function without closing, but an open ctx is relied on downstream.
299    // needs to be refactored.
300    if result.is_err() {
301        ctx.close().await;
302    }
303    result.map(|(state, env, graphics_result)| (state, ctx, env, graphics_result))
304}
305
306pub async fn execute_and_snapshot_no_auth(
307    code: &str,
308    current_file: Option<PathBuf>,
309) -> Result<(image::DynamicImage, EnvironmentRef), ExecError> {
310    let program = Program::parse_no_errs(code).map_err(KclErrorWithOutputs::no_outputs)?;
311    let version = program.language_version().map_err(KclErrorWithOutputs::no_outputs)?;
312    let ctx = new_context_engine_graphics(false, current_file, version).await?;
313    let res = execute_locally_and_render_on_engine(&ctx, program, None)
314        .await
315        .map(|(_, env_ref, image)| (image, env_ref))
316        .map_err(|err| err.error);
317    ctx.close().await;
318    res
319}
320
321async fn do_execute(
322    ctx: &ExecutorContext,
323    program: Program,
324    _deprecation_version_override: Option<&str>,
325) -> Result<(ExecState, EnvironmentRef), ExecErrorWithState> {
326    let mut exec_state = ExecState::new(ctx);
327    #[cfg(test)]
328    exec_state.set_deprecation_version_override(_deprecation_version_override);
329    let _ = ctx
330        .send_clear_scene(Some(program.kcl_version), &mut exec_state, Default::default())
331        .await;
332    let result = ctx.run(&program, &mut exec_state).await;
333    let responses = if result.is_err() {
334        #[cfg(feature = "snapshot-engine-responses")]
335        {
336            Some(exec_state.take_root_module_responses())
337        }
338        #[cfg(not(feature = "snapshot-engine-responses"))]
339        None
340    } else {
341        None
342    };
343    let result = result.map_err(|err| ExecErrorWithState::new(err.into(), exec_state.clone(), responses))?;
344    for issue in exec_state.issues() {
345        if issue.severity.is_err() {
346            return Err(ExecErrorWithState::new(
347                KclErrorWithOutputs::no_outputs(KclError::new_semantic(issue.clone().into())).into(),
348                exec_state.clone(),
349                None,
350            ));
351        }
352    }
353
354    Ok((exec_state, result.0))
355}
356
357pub async fn new_context_engine_graphics(
358    with_auth: bool,
359    current_file: Option<PathBuf>,
360    kcl_version: crate::KclVersion,
361) -> Result<ExecutorContext, ConnectionError> {
362    new_context_with_heartbeats(with_auth, current_file, None, false, kcl_version).await
363}
364
365pub async fn new_context(
366    with_auth: bool,
367    current_file: Option<PathBuf>,
368    geometry_only: bool,
369    kcl_version: crate::KclVersion,
370) -> Result<ExecutorContext, ConnectionError> {
371    new_context_with_heartbeats(with_auth, current_file, None, geometry_only, kcl_version).await
372}
373
374async fn new_context_with_heartbeats(
375    with_auth: bool,
376    current_file: Option<PathBuf>,
377    heartbeats: Option<u64>,
378    geometry_only: bool,
379    kcl_version: crate::KclVersion,
380) -> Result<ExecutorContext, ConnectionError> {
381    let mut client = new_zoo_client(if with_auth { None } else { Some("bad_token".to_string()) }, None)
382        .map_err(ConnectionError::CouldNotMakeClient)?;
383    if !with_auth {
384        // Use prod, don't override based on env vars.
385        // We do this so even in the engine repo, tests that need to run with
386        // no auth can fail in the same way as they would in prod.
387        client.set_base_url("https://api.zoo.dev".to_string());
388    }
389
390    let mut settings = ExecutorSettings {
391        highlight_edges: true,
392        enable_ssao: false,
393        show_grid: false,
394        replay: None,
395        project_directory: None,
396        current_file: None,
397        fixed_size_grid: true,
398        skip_artifact_graph: false,
399        heartbeats,
400        default_backface_color: Some("#00D5FF".to_owned()),
401        pool: None,
402        video_res_width: None,
403        video_res_height: None,
404        geometry_only,
405    };
406    if let Some(current_file) = current_file {
407        settings.with_current_file(crate::TypedPath(current_file));
408    }
409    let ctx = ExecutorContext::new(&client, settings, kcl_version)
410        .await
411        .map_err(ConnectionError::Establishing)?;
412    Ok(ctx)
413}
414
415pub async fn execute_and_export_step(
416    code: &str,
417    current_file: Option<PathBuf>,
418) -> Result<
419    (
420        ExecState,
421        EnvironmentRef,
422        Vec<kittycad_modeling_cmds::websocket::RawFile>,
423    ),
424    ExecErrorWithState,
425> {
426    let program = Program::parse_no_errs(code)
427        .map_err(KclErrorWithOutputs::no_outputs)
428        .map_err(ExecError::from)?;
429    let version = program
430        .language_version()
431        .map_err(KclErrorWithOutputs::no_outputs)
432        .map_err(ExecError::from)?;
433    let ctx = new_context(true, current_file, true, version).await?;
434    let mut exec_state = ExecState::new(&ctx);
435    let result = ctx
436        .run(&program, &mut exec_state)
437        .await
438        .map_err(|err| ExecErrorWithState::new(err.into(), exec_state.clone(), None))?;
439    for issue in exec_state.issues() {
440        if issue.severity.is_err() {
441            return Err(ExecErrorWithState::new(
442                KclErrorWithOutputs::no_outputs(KclError::new_semantic(issue.clone().into())).into(),
443                exec_state.clone(),
444                None,
445            ));
446        }
447    }
448
449    let files = match ctx.export_step(true).await {
450        Ok(f) => f,
451        Err(err) => {
452            return Err(ExecErrorWithState::new(
453                ExecError::BadExport(format!("Export failed: {err:?}")),
454                exec_state.clone(),
455                None,
456            ));
457        }
458    };
459
460    ctx.close().await;
461
462    Ok((exec_state, result.0, files))
463}