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