turn back on the test i tturned off (#6522)

* random other cahnges

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* turn back on test

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* docs

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* lots of enhancements

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* mesh test

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* mesh test

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* check panics

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* check panics

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* check panics

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* if running in vitest make single threadedd

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* check if running in vitest

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* console logs

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
This commit is contained in:
Jess Frazelle
2025-04-27 16:54:32 -07:00
committed by GitHub
parent 0dcb8baf64
commit 4439229ad2
42 changed files with 2186 additions and 1195 deletions

View File

@ -26,12 +26,8 @@ name: cargo bench
jobs:
cargo-bench:
name: cargo bench
runs-on:
- runs-on=${{ github.run_id }}
- runner=32cpu-linux-x64
- extras=s3-cache
runs-on: ubuntu-latest
steps:
- uses: runs-on/action@v1
- uses: actions/checkout@v4
- name: Use correct Rust toolchain
shell: bash

View File

@ -89,7 +89,6 @@ jobs:
env:
KITTYCAD_API_TOKEN: ${{secrets.KITTYCAD_API_TOKEN}}
RUST_BACKTRACE: full
RUST_MIN_STACK: 10485760000
- name: Commit differences
if: steps.path-changes.outputs.outside-kcl-samples == 'false' && steps.cargo-test-kcl-samples.outcome == 'failure'
shell: bash
@ -122,7 +121,6 @@ jobs:
# Configure nextest when it's run by insta (via just).
NEXTEST_PROFILE: ci
RUST_BACKTRACE: full
RUST_MIN_STACK: 10485760000
- name: cargo test
if: steps.path-changes.outputs.outside-kcl-samples == 'true'
shell: bash
@ -131,7 +129,6 @@ jobs:
cargo llvm-cov nextest --workspace --features artifact-graph --lcov --output-path lcov.info --retries=2 --no-fail-fast -P ci 2>&1 | tee /tmp/github-actions.log
env:
KITTYCAD_API_TOKEN: ${{secrets.KITTYCAD_API_TOKEN}}
RUST_MIN_STACK: 10485760000
- name: Upload to codecov.io
if: steps.path-changes.outputs.outside-kcl-samples == 'true'
uses: codecov/codecov-action@v5

View File

@ -6,7 +6,7 @@ layout: manual
Clone a sketch or solid.
This works essentially like a copy-paste operation.
This works essentially like a copy-paste operation. It creates a perfect replica at that point in time that you can manipulate individually afterwards.
This doesn't really have much utility unless you need the equivalent of a double instance pattern with zero transformations.

View File

@ -6,7 +6,7 @@ layout: manual
Start a new 2-dimensional sketch on a specific plane or face.
## Sketch on Face Behavior
### Sketch on Face Behavior
There are some important behaviors to understand when sketching on a face:

View File

@ -73770,7 +73770,7 @@
{
"name": "clone",
"summary": "Clone a sketch or solid.",
"description": "This works essentially like a copy-paste operation.\n\nThis doesn't really have much utility unless you need the equivalent of a double instance pattern with zero transformations.\n\nReally only use this function if YOU ARE SURE you need it. In most cases you do not need clone and using a pattern with `instance = 2` is more appropriate.",
"description": "This works essentially like a copy-paste operation. It creates a perfect replica at that point in time that you can manipulate individually afterwards.\n\nThis doesn't really have much utility unless you need the equivalent of a double instance pattern with zero transformations.\n\nReally only use this function if YOU ARE SURE you need it. In most cases you do not need clone and using a pattern with `instance = 2` is more appropriate.",
"tags": [],
"keywordArguments": true,
"args": [
@ -292719,7 +292719,7 @@
{
"name": "startSketchOn",
"summary": "Start a new 2-dimensional sketch on a specific plane or face.",
"description": "## Sketch on Face Behavior\n\nThere are some important behaviors to understand when sketching on a face:\n\nThe resulting sketch will _include_ the face and thus Solid that was sketched on. So say you were to export the resulting Sketch / Solid from a sketch on a face, you would get both the artifact of the sketch on the face and the parent face / Solid itself.\n\nThis is important to understand because if you were to then sketch on the resulting Solid, it would again include the face and parent Solid that was sketched on. This could go on indefinitely.\n\nThe point is if you want to export the result of a sketch on a face, you only need to export the final Solid that was created from the sketch on the face, since it will include all the parent faces and Solids.",
"description": "### Sketch on Face Behavior\n\nThere are some important behaviors to understand when sketching on a face:\n\nThe resulting sketch will _include_ the face and thus Solid that was sketched on. So say you were to export the resulting Sketch / Solid from a sketch on a face, you would get both the artifact of the sketch on the face and the parent face / Solid itself.\n\nThis is important to understand because if you were to then sketch on the resulting Solid, it would again include the face and parent Solid that was sketched on. This could go on indefinitely.\n\nThe point is if you want to export the result of a sketch on a face, you only need to export the final Solid that was created from the sketch on the face, since it will include all the parent faces and Solids.",
"tags": [],
"keywordArguments": true,
"args": [

View File

@ -694,7 +694,7 @@ washer
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
washer
washer
|> rotate(roll = 90, pitch = 0, yaw = 0)
clone001 = clone(washer)
`,

View File

@ -3406,7 +3406,7 @@ profile001 = startProfile(sketch001, at = [-20, 20])
radius = 500
)
sketch002 = startSketchOn(XZ)
|> startProfile(at = [0, 0])
|> startProfile(at = [0, 0])
|> xLine(length = -2000)
sweep001 = sweep(sketch001, path = sketch002)
`

View File

@ -1,14 +0,0 @@
const totalHeight = 5
const xVal = 3 + 2
sketch MySketch(myVar = 5) {
start(0, 0)
lineTo(5, myVar)
horzLineTo(10)
vertLineTo(0)
close()
}
const mySketch = MySketch(totalHeight)
print(`print worked: `, xVal)

965
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,7 @@
"vscode-languageserver-protocol": "^3.17.5",
"vscode-uri": "^3.1.0",
"web-vitals": "^3.5.2",
"why-is-node-running": "^3.2.2",
"win-ca": "^3.5.1",
"xstate": "^5.19.2",
"yargs": "^17.7.2"
@ -150,7 +151,11 @@
"test:unit:kcl-samples:local": "npm run simpleserver:bg && npm run test:unit:kcl-samples; kill-port 3000"
},
"browserslist": {
"production": [">0.2%", "not dead", "not op_mini all"],
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
@ -189,7 +194,7 @@
"@types/wicg-file-system-access": "^2023.10.6",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/web-worker": "^1.5.0",
"@vitest/web-worker": "^3.1.2",
"@xstate/cli": "^0.5.17",
"autoprefixer": "^10.4.21",
"dpdm": "^3.14.0",
@ -205,7 +210,7 @@
"eslint-plugin-react-perf": "^3.3.3",
"eslint-plugin-suggest-no-throw": "^1.0.0",
"eslint-plugin-testing-library": "^7.1.1",
"happy-dom": "^16.3.0",
"happy-dom": "^17.4.4",
"http-server": "^14.1.1",
"husky": "^9.1.7",
"kill-port": "^2.0.1",
@ -224,7 +229,7 @@
"vite-plugin-package-version": "^1.1.0",
"vite-plugin-top-level-await": "^1.5.0",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.6.1",
"vitest": "^3.1.2",
"vitest-webgl-canvas-mock": "^1.1.0",
"ws": "^8.18.1"
}

93
rust/Cargo.lock generated
View File

@ -758,7 +758,7 @@ dependencies = [
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core 0.9.10",
"parking_lot_core",
]
[[package]]
@ -772,7 +772,7 @@ dependencies = [
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core 0.9.10",
"parking_lot_core",
]
[[package]]
@ -847,7 +847,7 @@ dependencies = [
"backtrace",
"lazy_static",
"mintex",
"parking_lot 0.12.3",
"parking_lot",
"rustc-hash 1.1.0",
"serde",
"serde_json",
@ -1091,6 +1091,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
@ -1882,6 +1895,7 @@ dependencies = [
"bson",
"chrono",
"clap",
"console_error_panic_hook",
"convert_case",
"criterion",
"dashmap 6.1.0",
@ -1890,6 +1904,7 @@ dependencies = [
"fnv",
"form_urlencoded",
"futures",
"futures-lite",
"git_rev",
"gltf-json",
"handlebars",
@ -1934,10 +1949,10 @@ dependencies = [
"validator",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-timer",
"web-sys",
"web-time",
"winnow 0.6.24",
"zduny-wasm-timer",
"zip",
]
@ -2475,15 +2490,10 @@ dependencies = [
]
[[package]]
name = "parking_lot"
version = "0.11.2"
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
dependencies = [
"instant",
"lock_api",
"parking_lot_core 0.8.6",
]
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
@ -2492,21 +2502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
dependencies = [
"lock_api",
"parking_lot_core 0.9.10",
]
[[package]]
name = "parking_lot_core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
dependencies = [
"cfg-if",
"instant",
"libc",
"redox_syscall 0.2.16",
"smallvec",
"winapi",
"parking_lot_core",
]
[[package]]
@ -2517,7 +2513,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.10",
"redox_syscall",
"smallvec",
"windows-targets 0.52.6",
]
@ -3057,15 +3053,6 @@ dependencies = [
"rand_core 0.3.1",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "redox_syscall"
version = "0.5.10"
@ -4013,7 +4000,7 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot 0.12.3",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
@ -4610,21 +4597,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasm-timer"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f"
dependencies = [
"futures",
"js-sys",
"parking_lot 0.11.2",
"pin-utils",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.77"
@ -4986,6 +4958,21 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zduny-wasm-timer"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52bd30296679f51dce4a4da2a5050d9401d09866d89b89d860da37bc3ec08df"
dependencies = [
"futures",
"js-sys",
"parking_lot",
"pin-utils",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "zerocopy"
version = "0.7.35"

View File

@ -31,6 +31,7 @@ async-trait = "0.1.88"
anyhow = { version = "1" }
bson = { version = "2.13.0", features = ["uuid-1", "chrono"] }
clap = { version = "4.5.36", features = ["derive"] }
console_error_panic_hook = "0.1.7"
dashmap = { version = "6.1.0" }
http = "1"
indexmap = "2.7.0"

View File

@ -1,6 +1,6 @@
cnr := "cargo nextest run"
cita := "cargo insta test --accept"
kcl_lib_flags := "-p kcl-lib --feature artifact-graph --no-fail-fast"
kcl_lib_flags := "-p kcl-lib --features artifact-graph"
# Run the same lint checks we run in CI.
lint:

View File

@ -89,13 +89,15 @@ winnow = "=0.6.24"
zip = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = { workspace = true }
futures-lite = "2.6.0"
instant = { version = "0.1.13", features = ["wasm-bindgen", "inaccurate"] }
js-sys = { version = "0.3.72" }
tokio = { workspace = true, features = ["sync", "time"] }
tower-lsp = { workspace = true, features = ["runtime-agnostic"] }
wasm-bindgen = "0.2.99"
wasm-bindgen-futures = "0.4.49"
wasm-timer = "0.2.5"
wasm-timer = { package = "zduny-wasm-timer", version = "0.2.5" }
web-sys = { version = "0.3.76", features = ["console"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]

View File

@ -1,89 +1,116 @@
//! This module contains the `AsyncTasks` struct, which is used to manage a set of asynchronous
//! This module contains the wasm-specific `AsyncTasks` struct, which is used to manage a set of asynchronous
//! tasks.
use std::{ops::AddAssign, sync::Arc};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use tokio::sync::RwLock;
use tokio::sync::{mpsc, Notify};
use crate::errors::KclError;
#[derive(Debug, Clone)]
pub struct AsyncTasks {
pub sender: Arc<RwLock<tokio::sync::mpsc::Sender<Result<(), KclError>>>>,
pub receiver: Arc<RwLock<tokio::sync::mpsc::Receiver<Result<(), KclError>>>>,
pub sent: Arc<RwLock<usize>>,
// Results arrive here (unbounded = never blocks the producer)
tx: mpsc::UnboundedSender<Result<(), KclError>>,
rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<Result<(), KclError>>>>,
// How many tasks we started since last clear()
spawned: Arc<AtomicUsize>,
// Used to wake `join_all()` as soon as a task finishes.
notifier: Arc<Notify>,
}
impl AsyncTasks {
pub fn new() -> Self {
let (results_tx, results_rx) = tokio::sync::mpsc::channel(1);
Self {
sender: Arc::new(RwLock::new(results_tx)),
receiver: Arc::new(RwLock::new(results_rx)),
sent: Arc::new(RwLock::new(0)),
}
}
pub async fn spawn<F>(&mut self, task: F)
where
F: std::future::Future<Output = anyhow::Result<(), KclError>>,
F: Send + 'static,
{
// Add one to the sent counter.
self.sent.write().await.add_assign(1);
// Spawn the task and send the result to the channel.
let sender_clone = self.sender.clone();
wasm_bindgen_futures::spawn_local(async move {
let result = task.await;
let sender = sender_clone.read().await;
if let Err(_) = sender.send(result).await {
web_sys::console::error_1(&"Failed to send result".into());
}
});
}
// Wait for all tasks to finish.
// Return an error if any of them failed.
pub async fn join_all(&mut self) -> anyhow::Result<(), KclError> {
if *self.sent.read().await == 0 {
return Ok(());
}
let mut results = Vec::new();
let mut receiver = self.receiver.write().await;
// Wait for all tasks to finish.
while let Some(result) = receiver.recv().await {
results.push(result);
// Check if all tasks have finished.
if results.len() == *self.sent.read().await {
break;
}
}
// Check if any of the tasks failed.
for result in results {
result?;
}
Ok(())
}
pub async fn clear(&mut self) {
// Clear the sent counter.
*self.sent.write().await = 0;
// Clear the channel.
let (results_tx, results_rx) = tokio::sync::mpsc::channel(1);
*self.sender.write().await = results_tx;
*self.receiver.write().await = results_rx;
}
}
// Safety: single-threaded wasm ⇒ these are sound.
unsafe impl Send for AsyncTasks {}
unsafe impl Sync for AsyncTasks {}
impl Default for AsyncTasks {
fn default() -> Self {
Self::new()
}
}
impl AsyncTasks {
pub fn new() -> Self {
console_error_panic_hook::set_once();
let (tx, rx) = mpsc::unbounded_channel();
Self {
tx,
rx: Arc::new(tokio::sync::Mutex::new(rx)),
spawned: Arc::new(AtomicUsize::new(0)),
notifier: Arc::new(Notify::new()),
}
}
pub async fn spawn<F>(&mut self, fut: F)
where
F: std::future::Future<Output = anyhow::Result<(), KclError>> + Send + 'static,
{
self.spawned.fetch_add(1, Ordering::Relaxed);
let tx = self.tx.clone();
let notify = self.notifier.clone();
wasm_bindgen_futures::spawn_local(async move {
console_error_panic_hook::set_once();
let _ = tx.send(fut.await); // ignore if receiver disappeared
notify.notify_one(); // wake any join_all waiter
});
}
// Wait for all tasks to finish.
// Return an error if any of them failed.
pub async fn join_all(&mut self) -> anyhow::Result<(), KclError> {
let total = self.spawned.load(Ordering::Acquire);
if total == 0 {
return Ok(());
}
let mut done = 0;
while done < total {
// 1) Drain whatever is already in the channel
{
let mut rx = self.rx.lock().await;
while let Ok(res) = rx.try_recv() {
done += 1;
res?; // propagate first Err
}
}
if done >= total {
break;
}
// Yield to the event loop so that we don't block the UI thread.
// No seriously WE DO NOT WANT TO PAUSE THE WHOLE APP ON THE JS SIDE.
futures_lite::future::yield_now().await;
// Check again before waiting to avoid missing notifications
{
let mut rx = self.rx.lock().await;
while let Ok(res) = rx.try_recv() {
done += 1;
res?; // propagate first Err
if done >= total {
break;
}
}
}
// Only wait for notification if we still need more tasks to complete
if done < total {
// 2) Nothing ready yet → wait for a notifier poke
self.notifier.notified().await;
}
}
Ok(())
}
pub async fn clear(&mut self) {
self.spawned.store(0, Ordering::Release);
// Drain channel so old results dont confuse the next join_all.
let mut rx = self.rx.lock().await;
while rx.try_recv().is_ok() {}
}
}

View File

@ -229,18 +229,20 @@ pub trait EngineManager: std::fmt::Debug + Send + Sync + 'static {
while current_time.elapsed().as_secs() < 60 {
let responses = self.responses().read().await.clone();
let Some(resp) = responses.get(&id) else {
// Sleep for a little so we don't hog the CPU.
// Yield to the event loop so that we dont block the UI thread.
// No seriously WE DO NOT WANT TO PAUSE THE WHOLE APP ON THE JS SIDE.
let duration = instant::Duration::from_millis(100);
#[cfg(target_arch = "wasm32")]
wasm_timer::Delay::new(duration).await.map_err(|err| {
KclError::Internal(KclErrorDetails {
message: format!("Failed to sleep: {:?}", err),
source_ranges: vec![source_range],
})
})?;
{
let duration = instant::Duration::from_millis(1);
wasm_timer::Delay::new(duration).await.map_err(|err| {
KclError::Internal(KclErrorDetails {
message: format!("Failed to sleep: {:?}", err),
source_ranges: vec![source_range],
})
})?;
}
#[cfg(not(target_arch = "wasm32"))]
tokio::time::sleep(duration).await;
tokio::task::yield_now().await;
continue;
};

View File

@ -2630,3 +2630,25 @@ mod multiple_foreign_imports_all_render {
super::execute(TEST_NAME, true).await
}
}
mod import_mesh_clone {
const TEST_NAME: &str = "import_mesh_clone";
/// Test parsing KCL.
#[test]
fn parse() {
super::parse(TEST_NAME)
}
/// Test that parsing and unparsing KCL produces the original KCL input.
#[tokio::test(flavor = "multi_thread")]
async fn unparse() {
super::unparse(TEST_NAME).await
}
/// Test that KCL is executed correctly.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "turn on when katie fixes the mesh import"]
async fn kcl_test_execute() {
super::execute(TEST_NAME, true).await
}
}

View File

@ -43,7 +43,8 @@ pub async fn clone(exec_state: &mut ExecState, args: Args) -> Result<KclValue, K
/// Clone a sketch or solid.
///
/// This works essentially like a copy-paste operation.
/// This works essentially like a copy-paste operation. It creates a perfect replica
/// at that point in time that you can manipulate individually afterwards.
///
/// This doesn't really have much utility unless you need the equivalent of a double
/// instance pattern with zero transformations.

View File

@ -339,8 +339,12 @@ pub(crate) async fn do_post_extrude<'a>(
let get_all_edge_faces_next_uuid = exec_state.next_uuid();
#[cfg(test)]
let single_threaded = exec_state.single_threaded;
#[cfg(not(test))]
#[cfg(all(not(test), not(target_arch = "wasm32")))]
let single_threaded = false;
// When running in vitest, we need to run this in a single thread.
// Because their workers are complete shit.
#[cfg(all(target_arch = "wasm32", not(test)))]
let single_threaded = crate::wasm::vitest::running_in_vitest();
// Get faces for original edge
// Since this one is batched we can just run it.

View File

@ -982,7 +982,7 @@ pub async fn start_sketch_on(exec_state: &mut ExecState, args: Args) -> Result<K
/// Start a new 2-dimensional sketch on a specific plane or face.
///
/// ## Sketch on Face Behavior
/// ### Sketch on Face Behavior
///
/// There are some important behaviors to understand when sketching on a face:
///

View File

@ -1,4 +1,7 @@
//! Web assembly utils.
pub mod vitest;
use std::{
pin::Pin,
task::{Context, Poll},

View File

@ -0,0 +1,43 @@
use js_sys::Reflect;
use wasm_bindgen::JsValue;
/// returns true if globalThis.process?.env?.VITEST is truthy
fn is_vitest_by_env() -> bool {
let global = js_sys::global();
// global.process
let process = Reflect::get(&global, &JsValue::from_str("process"))
.ok()
.unwrap_or_else(|| JsValue::NULL);
// process.env
let env = Reflect::get(&process, &JsValue::from_str("env"))
.ok()
.unwrap_or_else(|| JsValue::NULL);
// env.VITEST
let vitest = Reflect::get(&env, &JsValue::from_str("VITEST"))
.ok()
.unwrap_or_else(|| JsValue::NULL);
// "true", "1", or a boolean
vitest
.as_bool()
.unwrap_or_else(|| vitest.as_string().map_or(false, |s| s == "true" || s == "1"))
}
fn is_vitest_by_global() -> bool {
let global = js_sys::global();
Reflect::has(&global, &JsValue::from_str("__vitest_worker__")).unwrap_or(false)
}
pub fn running_in_vitest() -> bool {
let running_in_vitest = is_vitest_by_env() || is_vitest_by_global();
if running_in_vitest {
web_sys::console::log_1(&JsValue::from_str(&format!(
"running_in_vitest: {}, SOME BEHAVIOR MIGHT BE DIFFERENT THAN THE WASM IN THE APP",
running_in_vitest
)));
}
running_in_vitest
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Artifact graph flowchart import_mesh_clone.kcl
extension: md
snapshot_kind: binary
---

View File

@ -0,0 +1,3 @@
```mermaid
flowchart LR
```

View File

@ -0,0 +1,303 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Result of parsing import_mesh_clone.kcl
---
{
"Ok": {
"body": [
{
"commentStart": 0,
"end": 0,
"path": {
"type": "Foreign",
"path": "../inputs/cube.obj"
},
"selector": {
"type": "None",
"alias": {
"commentStart": 0,
"end": 0,
"name": "cube",
"start": 0,
"type": "Identifier"
}
},
"start": 0,
"type": "ImportStatement",
"type": "ImportStatement"
},
{
"commentStart": 0,
"declaration": {
"commentStart": 0,
"end": 0,
"id": {
"commentStart": 0,
"end": 0,
"name": "model",
"start": 0,
"type": "Identifier"
},
"init": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "cube",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
},
"start": 0,
"type": "VariableDeclarator"
},
"end": 0,
"kind": "const",
"start": 0,
"type": "VariableDeclaration",
"type": "VariableDeclaration"
},
{
"commentStart": 0,
"declaration": {
"commentStart": 0,
"end": 0,
"id": {
"commentStart": 0,
"end": 0,
"name": "model2",
"start": 0,
"type": "Identifier"
},
"init": {
"body": [
{
"arguments": [
{
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "model",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "clone",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"
},
{
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "x",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "1020",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 1020.0,
"suffix": "None"
}
}
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "translate",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": null
},
{
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "color",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "\"#ff0000\"",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": "#ff0000"
}
},
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "metalness",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "50",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 50.0,
"suffix": "None"
}
}
},
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "roughness",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "50",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 50.0,
"suffix": "None"
}
}
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "appearance",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": null
}
],
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeExpression",
"type": "PipeExpression"
},
"start": 0,
"type": "VariableDeclarator"
},
"end": 0,
"kind": "const",
"start": 0,
"type": "VariableDeclaration",
"type": "VariableDeclaration"
}
],
"commentStart": 0,
"end": 0,
"nonCodeMeta": {
"nonCodeNodes": {
"0": [
{
"commentStart": 0,
"end": 0,
"start": 0,
"type": "NonCodeNode",
"value": {
"type": "newLine"
}
}
],
"1": [
{
"commentStart": 0,
"end": 0,
"start": 0,
"type": "NonCodeNode",
"value": {
"type": "newLine"
}
}
]
},
"startNodes": []
},
"start": 0
}
}

View File

@ -0,0 +1,19 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Error from executing import_mesh_clone.kcl
---
KCL Internal error
× internal: failed to fix tags and references: engine: KclErrorDetails
│ { source_ranges: [SourceRange([60, 72, 0])], message: "Modeling command
│ failed: [ApiError { error_code: BadRequest, message: \"Entity type does
│ not currently support transform patterns.\" }, ApiError { error_code:
│ InternalEngine, message: \"Failed to clone entity.\" }, ApiError
│ { error_code: InternalEngine, message: \"Failed to clone entity\" }]" }
╭─[5:10]
4 │
5 │ model2 = clone(model)
· ──────┬─────
· ╰── tests/import_mesh_clone/input.kcl
6 │ |> translate(
╰────

View File

@ -0,0 +1,13 @@
import "../inputs/cube.obj" as cube
model = cube
model2 = clone(model)
|> translate(
x = 1020,
)
|> appearance(
color = "#ff0000",
metalness = 50,
roughness = 50
)

View File

@ -0,0 +1,34 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Operations executed import_mesh_clone.kcl
---
[
{
"type": "GroupBegin",
"group": {
"type": "ModuleInstance",
"name": "cube",
"moduleId": 6
},
"sourceRange": []
},
{
"type": "GroupEnd"
},
{
"isError": true,
"labeledArgs": {
"geometry": {
"value": {
"type": "ImportedGeometry",
"artifact_id": "[uuid]"
},
"sourceRange": []
}
},
"name": "clone",
"sourceRange": [],
"type": "StdLibCall",
"unlabeledArg": null
}
]

View File

@ -0,0 +1,11 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Result of unparsing import_mesh_clone.kcl
---
import "../inputs/cube.obj" as cube
model = cube
model2 = clone(model)
|> translate(x = 1020)
|> appearance(color = "#ff0000", metalness = 50, roughness = 50)

View File

@ -12,7 +12,7 @@ bench = false
[target.'cfg(target_arch = "wasm32")'.dependencies]
bson = { workspace = true, features = ["uuid-1", "chrono"] }
console_error_panic_hook = "0.1.7"
console_error_panic_hook = { workspace = true }
data-encoding = "2.6.0"
futures = "0.3.31"
# Enable the feature in a transitive dependency.

View File

@ -1,6 +0,0 @@
face myRadius = radius of 5 at 0, 5
solid extrude myRadius by 5
showPart(solid)

View File

@ -3598,11 +3598,20 @@ export class SceneEntities {
entity_id: entityId,
},
})
const resp = await this.engineCommandManager.sendSceneCommand({
let resp = await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'get_sketch_mode_plane' },
})
if (!resp) {
return Promise.reject('no response')
}
if (isArray(resp)) {
resp = resp[0]
}
const faceInfo =
resp?.success &&
resp?.resp.type === 'modeling' &&

View File

@ -30,12 +30,16 @@ describe('UpdaterRestartModal tests', () => {
expect(updateButton).toBeEnabled()
fireEvent.click(updateButton)
expect(callback.mock.calls).toHaveLength(1)
expect(callback.mock.lastCall[0]).toEqual({ wantRestart: true })
expect(callback.mock?.lastCall ? callback.mock?.lastCall[0] : null).toEqual(
{ wantRestart: true }
)
const cancelButton = screen.getByTestId('update-restrart-button-cancel')
expect(cancelButton).toBeEnabled()
fireEvent.click(cancelButton)
expect(callback.mock.calls).toHaveLength(2)
expect(callback.mock.lastCall[0]).toEqual({ wantRestart: false })
expect(callback.mock?.lastCall ? callback.mock?.lastCall[0] : null).toEqual(
{ wantRestart: false }
)
})
})

View File

@ -1,7 +1,6 @@
import fs from 'node:fs'
import { KCLError } from '@src/lang/errors'
import { defaultArtifactGraph } from '@src/lang/std/artifactGraph'
import { topLevelRange } from '@src/lang/util'
import type { Sketch } from '@src/lang/wasm'
import { assertParse, sketchFromKclValue } from '@src/lang/wasm'
@ -463,11 +462,11 @@ const theExtrude = startSketchOn(XY)
new KCLError(
'undefined_value',
'`myVarZ` is not defined',
topLevelRange(129, 135),
[],
[],
defaultArtifactGraph(),
{},
topLevelRange(127, 133),
expect.any(Object),
expect.any(Object),
expect.any(Object),
expect.any(Object),
null
)
)

View File

@ -70,7 +70,8 @@ const dependencies = {
const runGetPathToExtrudeForSegmentSelectionTest = async (
code: string,
selectedSegmentSnippet: string,
expectedExtrudeSnippet: string
expectedExtrudeSnippet: string,
expectError?: boolean
) => {
// helpers
function getExtrudeExpression(
@ -142,6 +143,8 @@ const runGetPathToExtrudeForSegmentSelectionTest = async (
await kclManager.executeAst({ ast })
const artifactGraph = kclManager.artifactGraph
expect(kclManager.errors).toEqual([])
// find artifact
const maybeArtifact = [...artifactGraph].find(([, artifact]) => {
if (!('codeRef' in artifact && artifact.codeRef)) return false
@ -160,7 +163,12 @@ const runGetPathToExtrudeForSegmentSelectionTest = async (
selection,
artifactGraph
)
if (err(pathResult)) return pathResult
if (err(pathResult)) {
if (!expectError) {
expect(pathResult).toBeUndefined()
}
return pathResult
}
const { pathToExtrudeNode } = pathResult
const extrudeExpression = getExtrudeExpression(ast, pathToExtrudeNode)
@ -194,7 +202,7 @@ extrude001 = extrude(sketch001, length = -15)`
selectedSegmentSnippet,
expectedExtrudeSnippet
)
}, 5_000)
}, 10_000)
it('should return the correct paths when extrusion occurs within the sketch pipe', async () => {
const code = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
@ -324,9 +332,10 @@ extrude003 = extrude(sketch003, length = -15)`
await runGetPathToExtrudeForSegmentSelectionTest(
code,
selectedSegmentSnippet,
expectedExtrudeSnippet
expectedExtrudeSnippet,
true
)
}, 5_000)
}, 10_000)
})
const runModifyAstCloneWithEdgeTreatmentAndTag = async (
@ -351,6 +360,8 @@ const runModifyAstCloneWithEdgeTreatmentAndTag = async (
await kclManager.executeAst({ ast })
const artifactGraph = kclManager.artifactGraph
expect(kclManager.errors).toEqual([])
const selection: Selections = {
graphSelections: segmentRanges.map((segmentRange) => {
const maybeArtifact = [...artifactGraph].find(([, a]) => {
@ -373,6 +384,7 @@ const runModifyAstCloneWithEdgeTreatmentAndTag = async (
dependencies
)
if (err(result)) {
expect(result).toContain(expectedCode)
return result
}
const { modifiedAst } = result
@ -393,6 +405,8 @@ const runDeleteEdgeTreatmentTest = async (
await kclManager.executeAst({ ast })
const artifactGraph = kclManager.artifactGraph
expect(kclManager.errors).toEqual([])
// define snippet range
const edgeTreatmentRange = topLevelRange(
code.indexOf(edgeTreatmentSnippet),
@ -414,6 +428,7 @@ const runDeleteEdgeTreatmentTest = async (
// delete edge treatment
const result = await deleteEdgeTreatment(ast, selection)
if (err(result)) {
expect(result).toContain(expectedCode)
return result
}
@ -486,7 +501,7 @@ extrude001 = extrude(sketch001, length = -15, tagEnd = $capEnd001)
parameters,
expectedCode
)
})
}, 10_000)
it(`should add a ${edgeTreatmentType} to the sketch pipe`, async () => {
const code = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
@ -518,7 +533,7 @@ extrude001 = extrude(sketch001, length = -15, tagEnd = $capEnd001)
parameters,
expectedCode
)
})
}, 10_000)
it(`should add a ${edgeTreatmentType} to an already tagged segment`, async () => {
const code = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
@ -550,7 +565,7 @@ extrude001 = extrude(sketch001, length = -15, tagEnd = $capEnd001)
parameters,
expectedCode
)
})
}, 10_000)
it(`should add a ${edgeTreatmentType} with existing tag on other segment`, async () => {
const code = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
@ -582,7 +597,7 @@ extrude001 = extrude(sketch001, length = -15, tagEnd = $capEnd001)
parameters,
expectedCode
)
})
}, 10_000)
it(`should add a ${edgeTreatmentType} with existing fillet on other segment`, async () => {
const code = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
@ -703,7 +718,7 @@ extrude001 = extrude(sketch001, length = -15, tagEnd = $capEnd001)
parameters,
expectedCode
)
})
}, 10_000)
it(`should add ${edgeTreatmentType}s to two bodies`, async () => {
const code = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
@ -762,10 +777,9 @@ extrude002 = extrude(sketch002, length = -25, tagEnd = $capEnd002)
parameters,
expectedCode
)
})
}, 10_000)
})
// Skipping since something about the vite worker is suss.
describe.skip(`Testing deleteEdgeTreatment with ${edgeTreatmentType}s`, () => {
describe(`Testing deleteEdgeTreatment with ${edgeTreatmentType}s`, () => {
// simple cases
it(`should delete a piped ${edgeTreatmentType} from a single segment`, async () => {
const code = `sketch001 = startSketchOn(XY)
@ -818,7 +832,7 @@ extrude001 = extrude(sketch001, length = -15)`
edgeTreatmentSnippet,
expectedCode
)
})
}, 10_000)
// getOppositeEdge and getNextAdjacentEdge cases
it(`should delete a piped ${edgeTreatmentType} tagged with getOppositeEdge`, async () => {
const code = `sketch001 = startSketchOn(XY)
@ -845,7 +859,7 @@ extrude001 = extrude(sketch001, length = -15)`
edgeTreatmentSnippet,
expectedCode
)
})
}, 10_000)
it(`should delete a non-piped ${edgeTreatmentType} tagged with getNextAdjacentEdge`, async () => {
const code = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
@ -871,7 +885,7 @@ extrude001 = extrude(sketch001, length = -15)`
edgeTreatmentSnippet,
expectedCode
)
})
}, 10_000)
// cases with several edge treatments
it(`should delete a piped ${edgeTreatmentType} from a body with multiple treatments`, async () => {
const code = `sketch001 = startSketchOn(XY)
@ -904,7 +918,7 @@ chamfer001 = chamfer(extrude001, length = 5, tags = [getOppositeEdge(seg01)])`
edgeTreatmentSnippet,
expectedCode
)
})
}, 10_000)
it(`should delete a non-piped ${edgeTreatmentType} from a body with multiple treatments`, async () => {
const code = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
@ -936,7 +950,7 @@ chamfer001 = chamfer(extrude001, length = 5, tags = [getOppositeEdge(seg01)])`
edgeTreatmentSnippet,
expectedCode
)
})
}, 10_000)
})
}
)
@ -995,19 +1009,19 @@ describe('Testing button states', () => {
// body is missing
it('should return false when body is missing and nothing is selected', async () => {
await runButtonStateTest(codeWithoutBodies, '', false)
})
}, 10_000)
it('should return false when body is missing and segment is selected', async () => {
await runButtonStateTest(codeWithoutBodies, `line(end = [10, 0])`, false)
})
}, 10_000)
// body exists
it('should return true when body exists and nothing is selected', async () => {
await runButtonStateTest(codeWithBody, '', true)
})
}, 10_000)
it('should return true when body exists and segment is selected', async () => {
await runButtonStateTest(codeWithBody, `line(end = [10, 0])`, true)
})
}, 10_000)
it('should return false when body exists and not a segment is selected', async () => {
await runButtonStateTest(codeWithBody, `close()`, false)
})
}, 10_000)
})

View File

@ -309,16 +309,10 @@ class EngineConnection extends EventTarget {
private engineCommandManager: EngineCommandManager
private pingPongSpan: { ping?: number; pong?: number }
private pingIntervalId: ReturnType<typeof setInterval> = setInterval(
() => {},
60_000
)
private pingIntervalId: ReturnType<typeof setInterval> | null = null
isUsingConnectionLite: boolean = false
timeoutToForceConnectId: ReturnType<typeof setTimeout> = setTimeout(
() => {},
3000
)
timeoutToForceConnectId: ReturnType<typeof setTimeout> | null = null
constructor({
engineCommandManager,
@ -391,8 +385,6 @@ class EngineConnection extends EventTarget {
this.websocket?.addEventListener('message', ((event: MessageEvent) => {
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
const pending =
this.engineCommandManager.pendingCommands[message.request_id || '']
if (!('resp' in message)) return
let resp = message.resp
@ -412,54 +404,7 @@ class EngineConnection extends EventTarget {
return
}
if (
!(
pending &&
message.success &&
(message.resp.type === 'modeling' ||
message.resp.type === 'modeling_batch')
)
)
return
if (
message.resp.type === 'modeling' &&
pending.command.type === 'modeling_cmd_req' &&
message.request_id
) {
this.engineCommandManager.responseMap[message.request_id] = message.resp
} else if (
message.resp.type === 'modeling_batch' &&
pending.command.type === 'modeling_cmd_batch_req'
) {
let individualPendingResponses: {
[key: string]: Models['WebSocketRequest_type']
} = {}
pending.command.requests.forEach(({ cmd, cmd_id }) => {
individualPendingResponses[cmd_id] = {
type: 'modeling_cmd_req',
cmd,
cmd_id,
}
})
Object.entries(message.resp.data.responses).forEach(
([commandId, response]) => {
if (!('response' in response)) return
const command = individualPendingResponses[commandId]
if (!command) return
if (command.type === 'modeling_cmd_req')
this.engineCommandManager.responseMap[commandId] = {
type: 'modeling',
data: {
modeling_response: response.response,
},
}
}
)
}
pending.resolve([message])
delete this.engineCommandManager.pendingCommands[message.request_id || '']
this.engineCommandManager.handleMessage(event)
}) as EventListener)
}
@ -473,7 +418,12 @@ class EngineConnection extends EventTarget {
tearDown(opts?: { idleMode: boolean }) {
this.idleMode = opts?.idleMode ?? false
clearInterval(this.pingIntervalId)
if (this.pingIntervalId) {
clearInterval(this.pingIntervalId)
}
if (this.timeoutToForceConnectId) {
clearTimeout(this.timeoutToForceConnectId)
}
this.disconnectAll()
@ -1230,9 +1180,7 @@ class EngineConnection extends EventTarget {
)
}
disconnectAll() {
clearTimeout(this.timeoutToForceConnectId)
if (this.websocket?.readyState === 1) {
if (this.websocket && this.websocket?.readyState < 3) {
this.websocket?.close()
}
if (this.unreliableDataChannel?.readyState === 'open') {
@ -1322,7 +1270,10 @@ interface PendingMessage {
range: SourceRange
idToRangeMap: { [key: string]: SourceRange }
resolve: (data: [Models['WebSocketResponse_type']]) => void
reject: (reason: string) => void
// BOTH resolve and reject get passed back to the rust side which
// assumes it is this type! Do not change it!
// Format your errors as this type!
reject: (reason: [Models['WebSocketResponse_type']]) => void
promise: Promise<[Models['WebSocketResponse_type']]>
isSceneCommand: boolean
}
@ -1595,117 +1546,7 @@ export class EngineCommandManager extends EventTarget {
engineConnection.websocket?.addEventListener('message', ((
event: MessageEvent
) => {
let message: Models['WebSocketResponse_type'] | null = null
if (event.data instanceof ArrayBuffer) {
// BSON deserialize the command.
message = BSON.deserialize(
new Uint8Array(event.data)
) as Models['WebSocketResponse_type']
// The request id comes back as binary and we want to get the uuid
// string from that.
if (message.request_id) {
message.request_id = binaryToUuid(message.request_id)
}
} else {
message = JSON.parse(event.data)
}
if (message === null) {
// We should never get here.
console.error('Received a null message from the engine', event)
return
}
// In either case we want to send the response back over the wire to
// the rust side.
this.rustContext?.sendResponse(message).catch((err) => {
console.error('Error sending response to rust', err)
})
const pending = this.pendingCommands[message.request_id || '']
if (pending && !message.success) {
// handle bad case
pending.reject(JSON.stringify(message))
delete this.pendingCommands[message.request_id || '']
}
if (
!(
pending &&
message.success &&
(message.resp.type === 'modeling' ||
message.resp.type === 'modeling_batch' ||
message.resp.type === 'export')
)
)
return
if (message.resp.type === 'export' && message.request_id) {
this.responseMap[message.request_id] = message.resp
} else if (
message.resp.type === 'modeling' &&
pending.command.type === 'modeling_cmd_req' &&
message.request_id
) {
this.addCommandLog({
type: CommandLogType.ReceiveReliable,
data: message.resp,
id: message?.request_id || '',
cmd_type: pending?.command?.cmd?.type,
})
const modelingResponse = message.resp.data.modeling_response
Object.values(
this.subscriptions[modelingResponse.type] || {}
).forEach((callback) => callback(modelingResponse))
this.responseMap[message.request_id] = message.resp
} else if (
message.resp.type === 'modeling_batch' &&
pending.command.type === 'modeling_cmd_batch_req'
) {
let individualPendingResponses: {
[key: string]: Models['WebSocketRequest_type']
} = {}
pending.command.requests.forEach(({ cmd, cmd_id }) => {
individualPendingResponses[cmd_id] = {
type: 'modeling_cmd_req',
cmd,
cmd_id,
}
})
Object.entries(message.resp.data.responses).forEach(
([commandId, response]) => {
if (!('response' in response)) return
const command = individualPendingResponses[commandId]
if (!command) return
if (command.type === 'modeling_cmd_req')
this.addCommandLog({
type: CommandLogType.ReceiveReliable,
data: {
type: 'modeling',
data: {
modeling_response: response.response,
},
},
id: commandId,
cmd_type: command?.cmd?.type,
})
this.responseMap[commandId] = {
type: 'modeling',
data: {
modeling_response: response.response,
},
}
}
)
}
pending.resolve([message])
delete this.pendingCommands[message.request_id || '']
this.handleMessage(event)
}) as EventListener)
this.onVideoTrackMute = () => {
@ -1737,6 +1578,132 @@ export class EngineCommandManager extends EventTarget {
return
}
handleMessage(event: MessageEvent) {
let message: Models['WebSocketResponse_type'] | null = null
if (event.data instanceof ArrayBuffer) {
// BSON deserialize the command.
message = BSON.deserialize(
new Uint8Array(event.data)
) as Models['WebSocketResponse_type']
// The request id comes back as binary and we want to get the uuid
// string from that.
if (message.request_id) {
message.request_id = binaryToUuid(message.request_id)
}
} else {
message = JSON.parse(event.data)
}
if (message === null) {
// We should never get here.
console.error('Received a null message from the engine', event)
return
}
if (message.request_id === undefined || message.request_id === null) {
// We only care about messages that have a request id, so we can
// ignore the rest.
return
}
// In either case (success / fail) we want to send the response back over the wire to
// the rust side.
this.rustContext?.sendResponse(message).catch((err) => {
console.error('Error sending response to rust', err)
})
const pending = this.pendingCommands[message.request_id || '']
if (pending && !message.success) {
// handle bad case
pending.reject([message])
delete this.pendingCommands[message.request_id || '']
}
if (
!(
pending &&
message.success &&
(message.resp.type === 'modeling' ||
message.resp.type === 'modeling_batch' ||
message.resp.type === 'export')
)
) {
if (pending) {
pending.reject([message])
delete this.pendingCommands[message.request_id || '']
}
return
}
pending.resolve([message])
delete this.pendingCommands[message.request_id || '']
if (message.resp.type === 'export' && message.request_id) {
this.responseMap[message.request_id] = message.resp
} else if (
message.resp.type === 'modeling' &&
pending.command.type === 'modeling_cmd_req' &&
message.request_id
) {
this.addCommandLog({
type: CommandLogType.ReceiveReliable,
data: message.resp,
id: message?.request_id || '',
cmd_type: pending?.command?.cmd?.type,
})
const modelingResponse = message.resp.data.modeling_response
Object.values(this.subscriptions[modelingResponse.type] || {}).forEach(
(callback) => callback(modelingResponse)
)
this.responseMap[message.request_id] = message.resp
} else if (
message.resp.type === 'modeling_batch' &&
pending.command.type === 'modeling_cmd_batch_req'
) {
let individualPendingResponses: {
[key: string]: Models['WebSocketRequest_type']
} = {}
pending.command.requests.forEach(({ cmd, cmd_id }) => {
individualPendingResponses[cmd_id] = {
type: 'modeling_cmd_req',
cmd,
cmd_id,
}
})
Object.entries(message.resp.data.responses).forEach(
([commandId, response]) => {
if (!('response' in response)) return
const command = individualPendingResponses[commandId]
if (!command) return
if (command.type === 'modeling_cmd_req')
this.addCommandLog({
type: CommandLogType.ReceiveReliable,
data: {
type: 'modeling',
data: {
modeling_response: response.response,
},
},
id: commandId,
cmd_type: command?.cmd?.type,
})
this.responseMap[commandId] = {
type: 'modeling',
data: {
modeling_response: response.response,
},
}
}
)
}
}
handleResize({ width, height }: { width: number; height: number }) {
if (!this.engineConnection?.isReady()) {
return
@ -1761,8 +1728,19 @@ export class EngineCommandManager extends EventTarget {
tearDown(opts?: { idleMode: boolean }) {
if (this.engineConnection) {
for (const pending of Object.values(this.pendingCommands)) {
pending.reject('no connection to send on')
for (const [cmdId, pending] of Object.entries(this.pendingCommands)) {
pending.reject([
{
success: false,
errors: [
{
error_code: 'connection_problem',
message: 'no connection to send on, tearing down',
},
],
},
])
delete this.pendingCommands[cmdId]
}
this.engineConnection?.removeEventListener?.(
@ -1852,7 +1830,9 @@ export class EngineCommandManager extends EventTarget {
async sendSceneCommand(
command: EngineCommand,
forceWebsocket = false
): Promise<Models['WebSocketResponse_type'] | null> {
): Promise<
Models['WebSocketResponse_type'] | [Models['WebSocketResponse_type']] | null
> {
if (this.engineConnection === undefined) {
return Promise.resolve(null)
}
@ -2059,10 +2039,17 @@ export class EngineCommandManager extends EventTarget {
* to the engine
*/
rejectAllModelingCommands(rejectionMessage: string) {
Object.values(this.pendingCommands).forEach(
({ reject, isSceneCommand }) =>
!isSceneCommand && reject(rejectionMessage)
)
for (const [cmdId, pending] of Object.entries(this.pendingCommands)) {
if (!pending.isSceneCommand) {
pending.reject([
{
success: false,
errors: [{ error_code: 'internal_api', message: rejectionMessage }],
},
])
delete this.pendingCommands[cmdId]
}
}
}
async setPlaneHidden(id: string, hidden: boolean) {

View File

@ -3,17 +3,11 @@ import { parseEngineErrorMessage } from '@src/lib/commandBarConfigs/validators'
describe('parseEngineErrorMessage', () => {
it('takes an engine error string and parses its json message', () => {
const engineError =
'engine error: [{"error_code":"internal_engine","message":"Trajectory curve must be G1 continuous (with continuous tangents)"}]'
const message = parseEngineErrorMessage(engineError)
'[{"success": false,"request_id": "e6c0104b-ec60-4779-8e98-722f0a5019ec","errors": [{"error_code": "internal_engine","message": "Trajectory curve must be G1 continuous (with continuous tangents)"}]}]'
const parsedEngineError = JSON.parse(engineError)
const message = parseEngineErrorMessage(parsedEngineError)
expect(message).toEqual(
'Trajectory curve must be G1 continuous (with continuous tangents)'
)
})
it('retuns undefined on strings with different formats', () => {
const s1 = 'engine error: []'
const s2 = 'blabla'
expect(parseEngineErrorMessage(s1)).toBeUndefined()
expect(parseEngineErrorMessage(s2)).toBeUndefined()
})
})

View File

@ -1,9 +1,8 @@
import type { Models } from '@kittycad/lib'
import type { ApiError_type } from '@kittycad/lib/dist/types/src/models'
import type { Selections } from '@src/lib/selections'
import { engineCommandManager, kclManager } from '@src/lib/singletons'
import { uuidv4 } from '@src/lib/utils'
import { isArray, uuidv4 } from '@src/lib/utils'
import type { CommandBarContext } from '@src/machines/commandBarMachine'
export const disableDryRunWithRetry = async (numberOfRetries = 3) => {
@ -24,7 +23,14 @@ export const disableDryRunWithRetry = async (numberOfRetries = 3) => {
}
// Takes a callback function and wraps it around enable_dry_run and disable_dry_run
export const dryRunWrapper = async (callback: () => Promise<any>) => {
export const dryRunWrapper = async (
callback: () => Promise<
| Models['WebSocketResponse_type']
| [Models['WebSocketResponse_type']]
| undefined
| null
>
): Promise<[Models['WebSocketResponse_type']] | undefined> => {
// Gotcha: What about race conditions?
try {
await engineCommandManager.sendSceneCommand({
@ -33,7 +39,15 @@ export const dryRunWrapper = async (callback: () => Promise<any>) => {
cmd: { type: 'enable_dry_run' },
})
const result = await callback()
return result
if (!result) {
return undefined
}
if (isArray(result)) {
return result
}
return [result]
} catch (e) {
console.error(e)
} finally {
@ -48,13 +62,24 @@ function isSelections(selections: unknown): selections is Selections {
)
}
export function parseEngineErrorMessage(engineError: string) {
const parts = engineError.split('engine error: ')
if (parts.length < 2) {
export function parseEngineErrorMessage(
engineErrors?: [Models['WebSocketResponse_type']]
): string | undefined {
if (!engineErrors) {
return undefined
}
const errors = JSON.parse(parts[1]) as ApiError_type[]
if (!engineErrors[0]) {
return undefined
}
const engineError = engineErrors[0]
if (engineError.success) {
return undefined
}
const errors = engineError.errors
if (!errors[0]) {
return undefined
}
@ -114,7 +139,7 @@ export const revolveAxisValidator = async ({
})
}
const result = await dryRunWrapper(command)
if (result?.success) {
if (result && result[0] && result[0].success) {
return true
}
@ -163,7 +188,7 @@ export const loftValidator = async ({
})
}
const result = await dryRunWrapper(command)
if (result?.success) {
if (result && result[0] && result[0].success) {
return true
}
@ -218,7 +243,7 @@ export const shellValidator = async ({
}
const result = await dryRunWrapper(command)
if (result?.success) {
if (result && result[0] && result[0].success) {
return true
}
@ -280,7 +305,7 @@ export const sweepValidator = async ({
}
const result = await dryRunWrapper(command)
if (result?.success) {
if (result && result[0] && result[0].success) {
return true
}

View File

@ -39,6 +39,7 @@ import {
import { err } from '@src/lib/trap'
import {
getNormalisedCoordinates,
isArray,
isNonNullable,
isOverlap,
uuidv4,
@ -672,7 +673,7 @@ export async function sendSelectEventToEngine(
engineStreamState.videoRef.current,
engineCommandManager.streamDimensions
)
const res = await engineCommandManager.sendSceneCommand({
let res = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'select_with_point',
@ -681,6 +682,13 @@ export async function sendSelectEventToEngine(
},
cmd_id: uuidv4(),
})
if (!res) {
return Promise.reject('no response')
}
if (isArray(res)) {
res = res[0]
}
if (
res?.success &&
res?.resp?.type === 'modeling' &&

View File

@ -62,3 +62,69 @@ vi.mock('three', async () => {
// Mock other 'three' exports if necessary
}
})
/// Cleanup the engine connection if we had one.
/*
import { afterAll } from 'vitest'
import { engineCommandManager } from '@src/lib/singletons'
afterEach(async () => {
const ws = engineCommandManager.engineConnection?.websocket as any
if (ws) {
await finishWebSocket(ws)
}
engineCommandManager.tearDown()
})
// 1⃣ make timers fake
beforeAll(() => vi.useFakeTimers())
// 2⃣ restore real timers when the suite is done
afterAll(() => vi.useRealTimers())
/// Cleanup fake timers
afterEach(() => {
vi.runOnlyPendingTimers()
vi.clearAllTimers()
})
async function finishWebSocket(ws: WebSocket) {
if (!ws) return
// Drop wss own 30-s watchdog *first*
clearTimeout((ws as any)._closeTimer)
// Politely ask to close
if (ws.readyState < 2) ws.close(1000)
// Wait up to 100 ms (real time) for the close to complete,
// then hard-kill synchronously.
await new Promise<void>((res) => {
const realTimer = setTimeout(() => {
ws.terminate() // nukes socket & DNS/TLS handles
res()
}, 100) // real timer, not affected by vi.useFakeTimers()
ws.once('close', () => {
clearTimeout(realTimer) // handshake succeeded
res()
})
})
}
/// Cleanup happyDOM
afterEach(() => (globalThis as any).happyDOM?.cancelAsync?.())
afterAll(() => {
for (const h of (process as any)._getActiveHandles()) {
if (h.constructor?.name === 'FSWatcher') {
console.log('FSWatcher:', h)
}
}
})
import why from 'why-is-node-running'
afterAll(() => {
console.error('\n----- LIVE HANDLES -----')
why() // prints sockets, timers, file-watchers with stacks
})*/

View File

@ -1,10 +1,6 @@
import { defineWorkspace } from 'vitest/config'
export default defineWorkspace([
'./vite.main.config.ts',
'./vite.base.config.ts',
'./vite.config.ts',
'./vite.preload.config.ts',
'./vite.renderer.config.ts',
'./packages/codemirror-lang-kcl/vitest.main.config.ts',
])