From 4b52bf7c40cc686586af3ceb161702bd8158809c Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 12 Apr 2026 11:47:43 +0000 Subject: [PATCH 1/4] fix(compositor): strip _sender/_rev from UpdateParams before deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI injects _sender and _rev into config updates for causal-consistency tracking. CompositorConfig uses deny_unknown_fields, so these transient fields caused deserialization to fail — breaking all compositor control. Strip _sender and _rev in apply_update_params before deserializing. Add regression tests to verify: - _sender/_rev are ignored during deserialization - truly unknown fields are still rejected Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- crates/nodes/src/video/compositor/mod.rs | 13 +++- crates/nodes/src/video/compositor/tests.rs | 75 ++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/crates/nodes/src/video/compositor/mod.rs b/crates/nodes/src/video/compositor/mod.rs index d516f19c..071b1140 100644 --- a/crates/nodes/src/video/compositor/mod.rs +++ b/crates/nodes/src/video/compositor/mod.rs @@ -800,7 +800,7 @@ impl ProcessorNode for CompositorNode { }, NodeControlMessage::UpdateParams(ref params) => { // Extract transient sync metadata before - // deserialization strips unknown fields. + // apply_update_params strips these fields. // Always overwrite (not conditionally set) so that // non-stamped UpdateParams clears stale values. self.config_sender = params.get("_sender") @@ -1320,9 +1320,18 @@ impl CompositorNode { limits: &GlobalCompositorConfig, image_overlays: &mut Arc<[Arc]>, text_overlays: &mut Arc<[Arc]>, - params: serde_json::Value, + mut params: serde_json::Value, stats_tracker: &mut NodeStatsTracker, ) { + // Strip transient sync metadata injected by the UI (`_sender`, + // `_rev`) before deserializing. `CompositorConfig` uses + // `deny_unknown_fields` so these would otherwise cause a + // deserialization error. + if let Some(obj) = params.as_object_mut() { + obj.remove("_sender"); + obj.remove("_rev"); + } + match serde_json::from_value::(params) { Ok(new_config) => { // Resource limits are enforced by the server-level diff --git a/crates/nodes/src/video/compositor/tests.rs b/crates/nodes/src/video/compositor/tests.rs index 70aa9969..4e6d56e9 100644 --- a/crates/nodes/src/video/compositor/tests.rs +++ b/crates/nodes/src/video/compositor/tests.rs @@ -2889,3 +2889,78 @@ fn test_rebuild_svg_reuses_bitmap_when_unchanged() { assert_eq!(rebuilt[0].width, initial_arc.width); assert_eq!(rebuilt[0].height, initial_arc.height); } + +// ── Regression: transient sync metadata must not break deserialization ──── + +/// `_sender` and `_rev` are injected by the UI for causal-consistency +/// tracking. `CompositorConfig` uses `deny_unknown_fields`, so these +/// transient fields must be stripped before deserialization. +/// +/// Regression test for: compositor control completely broken when the UI +/// stamps `_sender` / `_rev` into config updates. +#[test] +fn test_update_params_ignores_transient_sync_metadata() { + let limits = config::GlobalCompositorConfig::default(); + let mut config = CompositorConfig::default(); + let mut image_overlays: Arc<[Arc]> = Arc::from(vec![]); + let mut text_overlays: Arc<[Arc]> = Arc::from(vec![]); + let mut stats = NodeStatsTracker::new("test".to_string(), None); + + let original_width = config.width; + + // Params with transient metadata — must not cause deserialization failure. + let params = serde_json::json!({ + "_sender": "client-abc123", + "_rev": 42, + "width": 1920, + "height": 1080 + }); + + CompositorNode::apply_update_params( + &mut config, + &limits, + &mut image_overlays, + &mut text_overlays, + params, + &mut stats, + ); + + // Config should have been updated despite the extra metadata fields. + assert_ne!(config.width, original_width, "Config should have been updated"); + assert_eq!(config.width, 1920); + assert_eq!(config.height, 1080); +} + +/// Verify that truly unknown fields (not `_sender`/`_rev`) are still +/// rejected by `deny_unknown_fields`. +#[test] +fn test_update_params_rejects_truly_unknown_fields() { + let limits = config::GlobalCompositorConfig::default(); + let mut config = CompositorConfig::default(); + let mut image_overlays: Arc<[Arc]> = Arc::from(vec![]); + let mut text_overlays: Arc<[Arc]> = Arc::from(vec![]); + let mut stats = NodeStatsTracker::new("test".to_string(), None); + + let original_width = config.width; + + // Params with a genuinely unknown field — should be rejected. + let params = serde_json::json!({ + "width": 1920, + "totally_bogus_field": true + }); + + CompositorNode::apply_update_params( + &mut config, + &limits, + &mut image_overlays, + &mut text_overlays, + params, + &mut stats, + ); + + // Config should NOT have been updated — deserialization should fail. + assert_eq!( + config.width, original_width, + "Config must not change when unknown fields are present" + ); +} From 3dac1a9efa6fb99e53a3b416354b5abe7308b45c Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 12 Apr 2026 12:19:35 +0000 Subject: [PATCH 2/4] refactor(engine): strip _sender/_rev at engine level, add E2E param sync test Move transient sync metadata stripping (_sender, _rev) from the compositor into the engine's TuneNode dispatch path so all nodes with deny_unknown_fields benefit automatically. Add E2E regression test that verifies compositor param changes from the UI (opacity slider drag) are reflected in the server-side pipeline state via the REST API. This would have caught the _rev deserialization regression where CompositorConfig rejected all UpdateParams. Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- crates/engine/src/dynamic_actor.rs | 82 ++++++++ crates/nodes/src/video/compositor/mod.rs | 11 +- crates/nodes/src/video/compositor/tests.rs | 47 +---- e2e/tests/compositor-video-output.spec.ts | 234 ++++++++++++++++++--- 4 files changed, 295 insertions(+), 79 deletions(-) diff --git a/crates/engine/src/dynamic_actor.rs b/crates/engine/src/dynamic_actor.rs index 59d0e170..99b5937b 100644 --- a/crates/engine/src/dynamic_actor.rs +++ b/crates/engine/src/dynamic_actor.rs @@ -1900,6 +1900,12 @@ impl DynamicEngine { self.disconnect_nodes(from_node, from_pin, to_node, to_pin).await; }, EngineControlMessage::TuneNode { node_id, message } => { + // Strip transient sync metadata (`_sender`, `_rev`) that + // the UI injects for causal-consistency tracking. Node + // config structs use `deny_unknown_fields`, so these would + // cause deserialization errors if left in place. + let message = strip_transient_metadata(message); + if let Some(node) = self.live_nodes.get(&node_id) { if node.control_tx.send(message).await.is_err() { tracing::warn!( @@ -2016,3 +2022,79 @@ impl DynamicEngine { true // Continue running } } + +/// Strip transient sync metadata (`_sender`, `_rev`) from an +/// [`UpdateParams`] message. The UI injects these fields for +/// causal-consistency echo suppression, but node config structs +/// typically use `#[serde(deny_unknown_fields)]` and would reject +/// the extra keys. Stripping at the engine level keeps individual +/// nodes unaware of the transport-level metadata. +fn strip_transient_metadata(message: NodeControlMessage) -> NodeControlMessage { + match message { + NodeControlMessage::UpdateParams(mut params) => { + if let Some(obj) = params.as_object_mut() { + obj.remove("_sender"); + obj.remove("_rev"); + } + NodeControlMessage::UpdateParams(params) + }, + other => other, + } +} + +#[cfg(test)] +mod strip_metadata_tests { + use super::*; + + /// Regression test: the UI injects `_sender` and `_rev` into config + /// updates for causal-consistency tracking. Node config structs use + /// `deny_unknown_fields`, so the engine must strip these before + /// dispatching `UpdateParams` to nodes. + #[test] + fn strips_sender_and_rev_from_update_params() { + let msg = NodeControlMessage::UpdateParams(serde_json::json!({ + "_sender": "client-abc", + "_rev": 7, + "width": 1920, + "height": 1080 + })); + + let stripped = strip_transient_metadata(msg); + + match stripped { + NodeControlMessage::UpdateParams(v) => { + let obj = v.as_object().unwrap(); + assert!(!obj.contains_key("_sender"), "_sender should be stripped"); + assert!(!obj.contains_key("_rev"), "_rev should be stripped"); + assert_eq!(obj.get("width").unwrap(), 1920); + assert_eq!(obj.get("height").unwrap(), 1080); + }, + _ => panic!("Expected UpdateParams"), + } + } + + #[test] + fn leaves_non_update_params_unchanged() { + let msg = NodeControlMessage::Shutdown; + let result = strip_transient_metadata(msg); + assert!(matches!(result, NodeControlMessage::Shutdown)); + } + + #[test] + fn handles_update_params_without_metadata() { + let msg = NodeControlMessage::UpdateParams(serde_json::json!({ + "width": 1280 + })); + + let stripped = strip_transient_metadata(msg); + + match stripped { + NodeControlMessage::UpdateParams(v) => { + let obj = v.as_object().unwrap(); + assert_eq!(obj.len(), 1); + assert_eq!(obj.get("width").unwrap(), 1280); + }, + _ => panic!("Expected UpdateParams"), + } + } +} diff --git a/crates/nodes/src/video/compositor/mod.rs b/crates/nodes/src/video/compositor/mod.rs index 071b1140..7235a4ba 100644 --- a/crates/nodes/src/video/compositor/mod.rs +++ b/crates/nodes/src/video/compositor/mod.rs @@ -1320,18 +1320,9 @@ impl CompositorNode { limits: &GlobalCompositorConfig, image_overlays: &mut Arc<[Arc]>, text_overlays: &mut Arc<[Arc]>, - mut params: serde_json::Value, + params: serde_json::Value, stats_tracker: &mut NodeStatsTracker, ) { - // Strip transient sync metadata injected by the UI (`_sender`, - // `_rev`) before deserializing. `CompositorConfig` uses - // `deny_unknown_fields` so these would otherwise cause a - // deserialization error. - if let Some(obj) = params.as_object_mut() { - obj.remove("_sender"); - obj.remove("_rev"); - } - match serde_json::from_value::(params) { Ok(new_config) => { // Resource limits are enforced by the server-level diff --git a/crates/nodes/src/video/compositor/tests.rs b/crates/nodes/src/video/compositor/tests.rs index 4e6d56e9..c4ebf5d6 100644 --- a/crates/nodes/src/video/compositor/tests.rs +++ b/crates/nodes/src/video/compositor/tests.rs @@ -2890,49 +2890,12 @@ fn test_rebuild_svg_reuses_bitmap_when_unchanged() { assert_eq!(rebuilt[0].height, initial_arc.height); } -// ── Regression: transient sync metadata must not break deserialization ──── +// ── Regression: deny_unknown_fields validation ────────────────────────── -/// `_sender` and `_rev` are injected by the UI for causal-consistency -/// tracking. `CompositorConfig` uses `deny_unknown_fields`, so these -/// transient fields must be stripped before deserialization. -/// -/// Regression test for: compositor control completely broken when the UI -/// stamps `_sender` / `_rev` into config updates. -#[test] -fn test_update_params_ignores_transient_sync_metadata() { - let limits = config::GlobalCompositorConfig::default(); - let mut config = CompositorConfig::default(); - let mut image_overlays: Arc<[Arc]> = Arc::from(vec![]); - let mut text_overlays: Arc<[Arc]> = Arc::from(vec![]); - let mut stats = NodeStatsTracker::new("test".to_string(), None); - - let original_width = config.width; - - // Params with transient metadata — must not cause deserialization failure. - let params = serde_json::json!({ - "_sender": "client-abc123", - "_rev": 42, - "width": 1920, - "height": 1080 - }); - - CompositorNode::apply_update_params( - &mut config, - &limits, - &mut image_overlays, - &mut text_overlays, - params, - &mut stats, - ); - - // Config should have been updated despite the extra metadata fields. - assert_ne!(config.width, original_width, "Config should have been updated"); - assert_eq!(config.width, 1920); - assert_eq!(config.height, 1080); -} - -/// Verify that truly unknown fields (not `_sender`/`_rev`) are still -/// rejected by `deny_unknown_fields`. +/// Verify that truly unknown fields are rejected by `deny_unknown_fields`. +/// Transient sync metadata (`_sender`, `_rev`) is stripped at the engine +/// level before reaching nodes — see `strip_transient_metadata` in the +/// engine crate. #[test] fn test_update_params_rejects_truly_unknown_fields() { let limits = config::GlobalCompositorConfig::default(); diff --git a/e2e/tests/compositor-video-output.spec.ts b/e2e/tests/compositor-video-output.spec.ts index 23183c39..617acfc5 100644 --- a/e2e/tests/compositor-video-output.spec.ts +++ b/e2e/tests/compositor-video-output.spec.ts @@ -17,21 +17,21 @@ * composited colorbars sources. */ -import { test, expect, request } from '@playwright/test'; +import { test, expect, request } from "@playwright/test"; -import { ensureLoggedIn, getAuthHeaders } from './auth-helpers'; +import { ensureLoggedIn, getAuthHeaders } from "./auth-helpers"; import { type ConsoleErrorCollector, MOQ_BENIGN_PATTERNS, createConsoleErrorCollector, -} from './test-helpers'; -import { COMPOSITOR_COLORBARS_YAML } from './compositor-fixtures'; +} from "./test-helpers"; +import { COMPOSITOR_COLORBARS_YAML } from "./compositor-fixtures"; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -test.describe('Compositor Video Output — Two Colorbars Pipeline', () => { +test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { let collector: ConsoleErrorCollector; let sessionId: string | null = null; @@ -39,7 +39,7 @@ test.describe('Compositor Video Output — Two Colorbars Pipeline', () => { collector = createConsoleErrorCollector(page); }); - test('compositor pipeline runs, monitor shows LIVE node with canvas preview, interaction survives', async ({ + test("compositor pipeline runs, monitor shows LIVE node with canvas preview, interaction survives", async ({ page, baseURL, }) => { @@ -53,7 +53,7 @@ test.describe('Compositor Video Output — Two Colorbars Pipeline', () => { }); const sessionName = `compositor-output-test-${Date.now()}`; - const createResponse = await apiContext.post('/api/v1/sessions', { + const createResponse = await apiContext.post("/api/v1/sessions", { data: { name: sessionName, yaml: COMPOSITOR_COLORBARS_YAML, @@ -61,7 +61,10 @@ test.describe('Compositor Video Output — Two Colorbars Pipeline', () => { }); const responseText = await createResponse.text(); - expect(createResponse.ok(), `Failed to create session: ${responseText}`).toBeTruthy(); + expect( + createResponse.ok(), + `Failed to create session: ${responseText}`, + ).toBeTruthy(); const createData = JSON.parse(responseText) as { session_id: string }; sessionId = createData.session_id; @@ -70,43 +73,56 @@ test.describe('Compositor Video Output — Two Colorbars Pipeline', () => { // ── 2. Navigate to monitor view ─────────────────────────────────────── - await page.goto('/monitor'); + await page.goto("/monitor"); await ensureLoggedIn(page); - if (!page.url().includes('/monitor')) { - await page.goto('/monitor'); + if (!page.url().includes("/monitor")) { + await page.goto("/monitor"); } - await expect(page.getByTestId('monitor-view')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("monitor-view")).toBeVisible({ + timeout: 15_000, + }); // Wait for sessions list and click the session. - await expect(page.getByTestId('sessions-list')).toBeVisible({ timeout: 10_000 }); - const sessionItem = page.getByTestId('session-item').filter({ hasText: sessionName }).first(); + await expect(page.getByTestId("sessions-list")).toBeVisible({ + timeout: 10_000, + }); + const sessionItem = page + .getByTestId("session-item") + .filter({ hasText: sessionName }) + .first(); await expect(sessionItem).toBeVisible({ timeout: 10_000 }); await sessionItem.click(); // ── 3. Verify compositor node is visible and running ────────────────── - const compositorNode = page.locator('.react-flow__node').filter({ hasText: 'Compositor' }); + const compositorNode = page + .locator(".react-flow__node") + .filter({ hasText: "Compositor" }); await expect(compositorNode).toBeVisible({ timeout: 15_000 }); // Verify LIVE badge is visible on compositor node. - const liveBadge = compositorNode.getByText('LIVE'); + const liveBadge = compositorNode.getByText("LIVE"); await expect(liveBadge).toBeVisible({ timeout: 10_000 }); // ── 4. Verify canvas preview is visible and has content ───────────── - const canvasInner = compositorNode.locator('[data-canvas-width]'); + const canvasInner = compositorNode.locator("[data-canvas-width]"); await expect(canvasInner).toBeVisible({ timeout: 5_000 }); // The canvas preview renders layer bounding boxes (outlines) over a // dark background — it does NOT stream actual video frames. Verify // the canvas area exists and the layer boxes are drawn within it. - const layerBoxes = canvasInner.locator('.nodrag.nopan'); + const layerBoxes = canvasInner.locator(".nodrag.nopan"); await expect(layerBoxes.first()).toBeVisible({ timeout: 10_000 }); // ── 5. Verify both input layers exist ───────────────────────────────── - const inputLayer0 = compositorNode.getByText('Input 0', { exact: true }).first(); - const inputLayer1 = compositorNode.getByText('Input 1', { exact: true }).first(); + const inputLayer0 = compositorNode + .getByText("Input 0", { exact: true }) + .first(); + const inputLayer1 = compositorNode + .getByText("Input 1", { exact: true }) + .first(); await expect(inputLayer0).toBeVisible({ timeout: 5_000 }); await expect(inputLayer1).toBeVisible({ timeout: 5_000 }); @@ -115,20 +131,24 @@ test.describe('Compositor Video Output — Two Colorbars Pipeline', () => { await inputLayer1.click(); // Wait for inspector to render (slider becomes visible). - await expect(compositorNode.getByRole('slider').first()).toBeVisible({ timeout: 5_000 }); + await expect(compositorNode.getByRole("slider").first()).toBeVisible({ + timeout: 5_000, + }); // Opacity section should be visible. const opacitySection = compositorNode - .locator('div') + .locator("div") .filter({ hasText: /^Opacity/ }) - .filter({ has: page.getByRole('slider') }) + .filter({ has: page.getByRole("slider") }) .first(); await expect(opacitySection).toBeVisible({ timeout: 5_000 }); // ── 7. Switch to Input 0 — verify it also works ─────────────────────── await inputLayer0.click(); - await expect(compositorNode.getByRole('slider').first()).toBeVisible({ timeout: 5_000 }); + await expect(compositorNode.getByRole("slider").first()).toBeVisible({ + timeout: 5_000, + }); // LIVE badge should still be visible (pipeline survived interaction). await expect(liveBadge).toBeVisible({ timeout: 5_000 }); @@ -139,15 +159,175 @@ test.describe('Compositor Video Output — Two Colorbars Pipeline', () => { // ── 8. Verify other pipeline nodes are present ──────────────────────── // The pipeline should have pixel_convert, vp9_encoder, and moq_peer nodes. - const allNodes = page.locator('.react-flow__node'); + const allNodes = page.locator(".react-flow__node"); const nodeCount = await allNodes.count(); - expect(nodeCount, 'Pipeline should have multiple nodes').toBeGreaterThanOrEqual(4); + expect( + nodeCount, + "Pipeline should have multiple nodes", + ).toBeGreaterThanOrEqual(4); // ── 9. Console error check ──────────────────────────────────────────── const unexpected = collector.getUnexpected(MOQ_BENIGN_PATTERNS); if (unexpected.length > 0) { - console.warn('Unexpected console errors (non-fatal):', unexpected); + console.warn("Unexpected console errors (non-fatal):", unexpected); + } + }); + + // --------------------------------------------------------------------------- + // Regression test: compositor param changes from UI must reach the server. + // + // When the UI sends a compositor config update (e.g. opacity slider drag), + // the engine's TuneNode path strips transient sync metadata (_sender, _rev) + // before dispatching UpdateParams to the compositor node. If stripping + // fails (or the metadata is not stripped), CompositorConfig's + // deny_unknown_fields rejects the entire update — breaking all compositor + // control. This test verifies the full round-trip: UI slider → WS + // TuneNodeAsync → engine → compositor → pipeline API reflects the change. + // --------------------------------------------------------------------------- + + test("compositor param change from UI is reflected in server-side pipeline state", async ({ + page, + baseURL, + }) => { + test.setTimeout(120_000); + + // ── 1. Create compositor session via API ───────────────────────────── + + const apiContext = await request.newContext({ + baseURL: baseURL!, + extraHTTPHeaders: getAuthHeaders(), + }); + + const sessionName = `compositor-param-sync-${Date.now()}`; + const createResponse = await apiContext.post("/api/v1/sessions", { + data: { + name: sessionName, + yaml: COMPOSITOR_COLORBARS_YAML, + }, + }); + + const responseText = await createResponse.text(); + expect( + createResponse.ok(), + `Failed to create session: ${responseText}`, + ).toBeTruthy(); + + const createData = JSON.parse(responseText) as { session_id: string }; + sessionId = createData.session_id; + expect(sessionId).toBeTruthy(); + + // ── 2. Navigate to monitor and open the session ───────────────────── + + await page.goto("/monitor"); + await ensureLoggedIn(page); + if (!page.url().includes("/monitor")) { + await page.goto("/monitor"); + } + await expect(page.getByTestId("monitor-view")).toBeVisible({ + timeout: 15_000, + }); + + await expect(page.getByTestId("sessions-list")).toBeVisible({ + timeout: 10_000, + }); + const sessionItem = page + .getByTestId("session-item") + .filter({ hasText: sessionName }) + .first(); + await expect(sessionItem).toBeVisible({ timeout: 10_000 }); + await sessionItem.click(); + + // ── 3. Wait for compositor node LIVE ───────────────────────────────── + + const compositorNode = page + .locator(".react-flow__node") + .filter({ hasText: "Compositor" }); + await expect(compositorNode).toBeVisible({ timeout: 15_000 }); + await expect(compositorNode.getByText("LIVE")).toBeVisible({ + timeout: 10_000, + }); + + // ── 4. Select Input 1 and locate opacity slider ───────────────────── + + const inputLayer1 = compositorNode + .getByText("Input 1", { exact: true }) + .first(); + await expect(inputLayer1).toBeVisible({ timeout: 5_000 }); + await inputLayer1.click(); + + const opacitySection = compositorNode + .locator("div") + .filter({ hasText: /^Opacity/ }) + .filter({ has: page.getByRole("slider") }) + .first(); + await expect(opacitySection).toBeVisible({ timeout: 5_000 }); + + // ── 5. Drag opacity slider to change value ────────────────────────── + + const thumb = opacitySection.getByRole("slider"); + await thumb.waitFor({ state: "visible", timeout: 5_000 }); + const box = await thumb.boundingBox(); + expect(box, "Opacity slider thumb must have a bounding box").toBeTruthy(); + + // Drag the slider significantly to the left to reduce opacity. + const startX = box!.x + box!.width / 2; + const startY = box!.y + box!.height / 2; + await page.mouse.move(startX, startY); + await page.mouse.down(); + // Move in steps to simulate a realistic drag. + for (let i = 1; i <= 10; i++) { + await page.mouse.move(startX - i * 5, startY); + } + await page.mouse.up(); + + // Wait for debounced WS message to reach the server. + await page.waitForTimeout(1_000); + + // ── 6. Verify server-side pipeline state reflects the change ──────── + + const pipelineResponse = await apiContext.get( + `/api/v1/sessions/${sessionId}/pipeline`, + ); + expect(pipelineResponse.ok(), "Pipeline API should return OK").toBeTruthy(); + + const pipeline = (await pipelineResponse.json()) as { + nodes: Record }>; + }; + + const compositorParams = pipeline.nodes["compositor"]?.params; + expect( + compositorParams, + "Compositor node should have params in pipeline state", + ).toBeTruthy(); + + // The layers object should exist and in_1's opacity should have + // changed from the initial value of 0.9 (per the fixture YAML). + const layers = compositorParams!["layers"] as + | Record + | undefined; + expect(layers, "Compositor params should contain layers").toBeTruthy(); + expect( + layers!["in_1"], + "Layer in_1 should exist in compositor params", + ).toBeTruthy(); + + const newOpacity = layers!["in_1"]!.opacity; + expect(newOpacity, "in_1 opacity should be defined").toBeDefined(); + expect( + newOpacity, + `in_1 opacity should have changed from initial 0.9 (got ${newOpacity}). ` + + "If still 0.9, the UI param change did not reach the server — " + + "likely UpdateParams deserialization is failing (e.g. _rev/_sender not stripped).", + ).not.toBeCloseTo(0.9, 1); + + await apiContext.dispose(); + + // ── 7. Console error check ────────────────────────────────────────── + + const unexpected = collector.getUnexpected(MOQ_BENIGN_PATTERNS); + if (unexpected.length > 0) { + console.warn("Unexpected console errors (non-fatal):", unexpected); } }); From 06f49163691db9178984983ec3dfbd5a4b4f1b3a Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 12 Apr 2026 12:27:52 +0000 Subject: [PATCH 3/4] fix: move strip_sync_metadata to core utility, preserve echo suppression The previous commit stripped _sender/_rev at the engine level before dispatching to nodes. This broke the compositor's echo suppression mechanism because it reads those fields (lines 806-812) before apply_update_params deserializes the config. Fix: centralize the stripping logic as strip_sync_metadata() in the core crate. The compositor calls it in apply_update_params after reading the metadata for echo suppression. Other nodes with deny_unknown_fields can call the same utility before deserializing. This preserves the echo suppression mechanism while providing a single, reusable function for stripping transient sync metadata. Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- crates/core/src/control.rs | 61 ++++++++++++++++ crates/engine/src/dynamic_actor.rs | 82 ---------------------- crates/nodes/src/video/compositor/mod.rs | 8 ++- crates/nodes/src/video/compositor/tests.rs | 48 +++++++++++-- 4 files changed, 111 insertions(+), 88 deletions(-) diff --git a/crates/core/src/control.rs b/crates/core/src/control.rs index 11c73b1f..f73e90dd 100644 --- a/crates/core/src/control.rs +++ b/crates/core/src/control.rs @@ -44,6 +44,67 @@ pub enum ConnectionMode { BestEffort, } +/// Strip transient sync metadata (`_sender`, `_rev`) from a params JSON value. +/// +/// The UI injects these fields for causal-consistency echo suppression. +/// Node config structs that use `#[serde(deny_unknown_fields)]` will reject +/// them during deserialization, so they must be stripped before calling +/// `serde_json::from_value`. +/// +/// Nodes that need the metadata for echo suppression (e.g. the compositor) +/// should read `_sender`/`_rev` from the raw params *before* calling this +/// function. +pub fn strip_sync_metadata(params: &mut serde_json::Value) { + if let Some(obj) = params.as_object_mut() { + obj.remove("_sender"); + obj.remove("_rev"); + } +} + +#[cfg(test)] +mod strip_sync_metadata_tests { + use super::*; + + /// Regression test: `_sender` and `_rev` must be removed so that + /// node config structs with `deny_unknown_fields` can deserialize + /// successfully. + #[test] + fn strips_sender_and_rev() { + let mut params = serde_json::json!({ + "_sender": "client-abc", + "_rev": 7, + "width": 1920, + "height": 1080 + }); + + strip_sync_metadata(&mut params); + + let obj = params.as_object().unwrap(); + assert!(!obj.contains_key("_sender"), "_sender should be stripped"); + assert!(!obj.contains_key("_rev"), "_rev should be stripped"); + assert_eq!(obj.get("width").unwrap(), 1920); + assert_eq!(obj.get("height").unwrap(), 1080); + } + + #[test] + fn no_op_without_metadata() { + let mut params = serde_json::json!({ "width": 1280 }); + + strip_sync_metadata(&mut params); + + let obj = params.as_object().unwrap(); + assert_eq!(obj.len(), 1); + assert_eq!(obj.get("width").unwrap(), 1280); + } + + #[test] + fn no_op_on_non_object() { + let mut params = serde_json::json!(42); + strip_sync_metadata(&mut params); + assert_eq!(params, serde_json::json!(42)); + } +} + /// A message sent to the central Engine actor to modify the pipeline graph itself. #[derive(Debug)] pub enum EngineControlMessage { diff --git a/crates/engine/src/dynamic_actor.rs b/crates/engine/src/dynamic_actor.rs index 99b5937b..59d0e170 100644 --- a/crates/engine/src/dynamic_actor.rs +++ b/crates/engine/src/dynamic_actor.rs @@ -1900,12 +1900,6 @@ impl DynamicEngine { self.disconnect_nodes(from_node, from_pin, to_node, to_pin).await; }, EngineControlMessage::TuneNode { node_id, message } => { - // Strip transient sync metadata (`_sender`, `_rev`) that - // the UI injects for causal-consistency tracking. Node - // config structs use `deny_unknown_fields`, so these would - // cause deserialization errors if left in place. - let message = strip_transient_metadata(message); - if let Some(node) = self.live_nodes.get(&node_id) { if node.control_tx.send(message).await.is_err() { tracing::warn!( @@ -2022,79 +2016,3 @@ impl DynamicEngine { true // Continue running } } - -/// Strip transient sync metadata (`_sender`, `_rev`) from an -/// [`UpdateParams`] message. The UI injects these fields for -/// causal-consistency echo suppression, but node config structs -/// typically use `#[serde(deny_unknown_fields)]` and would reject -/// the extra keys. Stripping at the engine level keeps individual -/// nodes unaware of the transport-level metadata. -fn strip_transient_metadata(message: NodeControlMessage) -> NodeControlMessage { - match message { - NodeControlMessage::UpdateParams(mut params) => { - if let Some(obj) = params.as_object_mut() { - obj.remove("_sender"); - obj.remove("_rev"); - } - NodeControlMessage::UpdateParams(params) - }, - other => other, - } -} - -#[cfg(test)] -mod strip_metadata_tests { - use super::*; - - /// Regression test: the UI injects `_sender` and `_rev` into config - /// updates for causal-consistency tracking. Node config structs use - /// `deny_unknown_fields`, so the engine must strip these before - /// dispatching `UpdateParams` to nodes. - #[test] - fn strips_sender_and_rev_from_update_params() { - let msg = NodeControlMessage::UpdateParams(serde_json::json!({ - "_sender": "client-abc", - "_rev": 7, - "width": 1920, - "height": 1080 - })); - - let stripped = strip_transient_metadata(msg); - - match stripped { - NodeControlMessage::UpdateParams(v) => { - let obj = v.as_object().unwrap(); - assert!(!obj.contains_key("_sender"), "_sender should be stripped"); - assert!(!obj.contains_key("_rev"), "_rev should be stripped"); - assert_eq!(obj.get("width").unwrap(), 1920); - assert_eq!(obj.get("height").unwrap(), 1080); - }, - _ => panic!("Expected UpdateParams"), - } - } - - #[test] - fn leaves_non_update_params_unchanged() { - let msg = NodeControlMessage::Shutdown; - let result = strip_transient_metadata(msg); - assert!(matches!(result, NodeControlMessage::Shutdown)); - } - - #[test] - fn handles_update_params_without_metadata() { - let msg = NodeControlMessage::UpdateParams(serde_json::json!({ - "width": 1280 - })); - - let stripped = strip_transient_metadata(msg); - - match stripped { - NodeControlMessage::UpdateParams(v) => { - let obj = v.as_object().unwrap(); - assert_eq!(obj.len(), 1); - assert_eq!(obj.get("width").unwrap(), 1280); - }, - _ => panic!("Expected UpdateParams"), - } - } -} diff --git a/crates/nodes/src/video/compositor/mod.rs b/crates/nodes/src/video/compositor/mod.rs index 7235a4ba..984f7ca3 100644 --- a/crates/nodes/src/video/compositor/mod.rs +++ b/crates/nodes/src/video/compositor/mod.rs @@ -1320,9 +1320,15 @@ impl CompositorNode { limits: &GlobalCompositorConfig, image_overlays: &mut Arc<[Arc]>, text_overlays: &mut Arc<[Arc]>, - params: serde_json::Value, + mut params: serde_json::Value, stats_tracker: &mut NodeStatsTracker, ) { + // Strip transient sync metadata (`_sender`, `_rev`) before + // deserializing. The metadata has already been read by the + // caller for echo suppression — see the `UpdateParams` arm + // in the run loop. + streamkit_core::control::strip_sync_metadata(&mut params); + match serde_json::from_value::(params) { Ok(new_config) => { // Resource limits are enforced by the server-level diff --git a/crates/nodes/src/video/compositor/tests.rs b/crates/nodes/src/video/compositor/tests.rs index c4ebf5d6..125750db 100644 --- a/crates/nodes/src/video/compositor/tests.rs +++ b/crates/nodes/src/video/compositor/tests.rs @@ -2890,12 +2890,50 @@ fn test_rebuild_svg_reuses_bitmap_when_unchanged() { assert_eq!(rebuilt[0].height, initial_arc.height); } -// ── Regression: deny_unknown_fields validation ────────────────────────── +// ── Regression: transient sync metadata must not break deserialization ──── -/// Verify that truly unknown fields are rejected by `deny_unknown_fields`. -/// Transient sync metadata (`_sender`, `_rev`) is stripped at the engine -/// level before reaching nodes — see `strip_transient_metadata` in the -/// engine crate. +/// `_sender` and `_rev` are injected by the UI for causal-consistency +/// tracking. `CompositorConfig` uses `deny_unknown_fields`, so +/// `apply_update_params` strips these transient fields before +/// deserialization via `strip_sync_metadata`. +/// +/// Regression test for: compositor control completely broken when the UI +/// stamps `_sender` / `_rev` into config updates. +#[test] +fn test_update_params_ignores_transient_sync_metadata() { + let limits = config::GlobalCompositorConfig::default(); + let mut config = CompositorConfig::default(); + let mut image_overlays: Arc<[Arc]> = Arc::from(vec![]); + let mut text_overlays: Arc<[Arc]> = Arc::from(vec![]); + let mut stats = NodeStatsTracker::new("test".to_string(), None); + + let original_width = config.width; + + // Params with transient metadata — must not cause deserialization failure. + let params = serde_json::json!({ + "_sender": "client-abc123", + "_rev": 42, + "width": 1920, + "height": 1080 + }); + + CompositorNode::apply_update_params( + &mut config, + &limits, + &mut image_overlays, + &mut text_overlays, + params, + &mut stats, + ); + + // Config should have been updated despite the extra metadata fields. + assert_ne!(config.width, original_width, "Config should have been updated"); + assert_eq!(config.width, 1920); + assert_eq!(config.height, 1080); +} + +/// Verify that truly unknown fields (not `_sender`/`_rev`) are still +/// rejected by `deny_unknown_fields`. #[test] fn test_update_params_rejects_truly_unknown_fields() { let limits = config::GlobalCompositorConfig::default(); From 36a7d8ac784130372f69ed7778000eb01aee2f62 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 12 Apr 2026 12:39:27 +0000 Subject: [PATCH 4/4] style(e2e): fix prettier formatting in compositor test Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- e2e/tests/compositor-video-output.spec.ts | 147 +++++++++------------- 1 file changed, 56 insertions(+), 91 deletions(-) diff --git a/e2e/tests/compositor-video-output.spec.ts b/e2e/tests/compositor-video-output.spec.ts index 617acfc5..245e67c3 100644 --- a/e2e/tests/compositor-video-output.spec.ts +++ b/e2e/tests/compositor-video-output.spec.ts @@ -17,21 +17,21 @@ * composited colorbars sources. */ -import { test, expect, request } from "@playwright/test"; +import { test, expect, request } from '@playwright/test'; -import { ensureLoggedIn, getAuthHeaders } from "./auth-helpers"; +import { ensureLoggedIn, getAuthHeaders } from './auth-helpers'; import { type ConsoleErrorCollector, MOQ_BENIGN_PATTERNS, createConsoleErrorCollector, -} from "./test-helpers"; -import { COMPOSITOR_COLORBARS_YAML } from "./compositor-fixtures"; +} from './test-helpers'; +import { COMPOSITOR_COLORBARS_YAML } from './compositor-fixtures'; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { +test.describe('Compositor Video Output — Two Colorbars Pipeline', () => { let collector: ConsoleErrorCollector; let sessionId: string | null = null; @@ -39,7 +39,7 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { collector = createConsoleErrorCollector(page); }); - test("compositor pipeline runs, monitor shows LIVE node with canvas preview, interaction survives", async ({ + test('compositor pipeline runs, monitor shows LIVE node with canvas preview, interaction survives', async ({ page, baseURL, }) => { @@ -53,7 +53,7 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { }); const sessionName = `compositor-output-test-${Date.now()}`; - const createResponse = await apiContext.post("/api/v1/sessions", { + const createResponse = await apiContext.post('/api/v1/sessions', { data: { name: sessionName, yaml: COMPOSITOR_COLORBARS_YAML, @@ -61,10 +61,7 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { }); const responseText = await createResponse.text(); - expect( - createResponse.ok(), - `Failed to create session: ${responseText}`, - ).toBeTruthy(); + expect(createResponse.ok(), `Failed to create session: ${responseText}`).toBeTruthy(); const createData = JSON.parse(responseText) as { session_id: string }; sessionId = createData.session_id; @@ -73,56 +70,47 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { // ── 2. Navigate to monitor view ─────────────────────────────────────── - await page.goto("/monitor"); + await page.goto('/monitor'); await ensureLoggedIn(page); - if (!page.url().includes("/monitor")) { - await page.goto("/monitor"); + if (!page.url().includes('/monitor')) { + await page.goto('/monitor'); } - await expect(page.getByTestId("monitor-view")).toBeVisible({ + await expect(page.getByTestId('monitor-view')).toBeVisible({ timeout: 15_000, }); // Wait for sessions list and click the session. - await expect(page.getByTestId("sessions-list")).toBeVisible({ + await expect(page.getByTestId('sessions-list')).toBeVisible({ timeout: 10_000, }); - const sessionItem = page - .getByTestId("session-item") - .filter({ hasText: sessionName }) - .first(); + const sessionItem = page.getByTestId('session-item').filter({ hasText: sessionName }).first(); await expect(sessionItem).toBeVisible({ timeout: 10_000 }); await sessionItem.click(); // ── 3. Verify compositor node is visible and running ────────────────── - const compositorNode = page - .locator(".react-flow__node") - .filter({ hasText: "Compositor" }); + const compositorNode = page.locator('.react-flow__node').filter({ hasText: 'Compositor' }); await expect(compositorNode).toBeVisible({ timeout: 15_000 }); // Verify LIVE badge is visible on compositor node. - const liveBadge = compositorNode.getByText("LIVE"); + const liveBadge = compositorNode.getByText('LIVE'); await expect(liveBadge).toBeVisible({ timeout: 10_000 }); // ── 4. Verify canvas preview is visible and has content ───────────── - const canvasInner = compositorNode.locator("[data-canvas-width]"); + const canvasInner = compositorNode.locator('[data-canvas-width]'); await expect(canvasInner).toBeVisible({ timeout: 5_000 }); // The canvas preview renders layer bounding boxes (outlines) over a // dark background — it does NOT stream actual video frames. Verify // the canvas area exists and the layer boxes are drawn within it. - const layerBoxes = canvasInner.locator(".nodrag.nopan"); + const layerBoxes = canvasInner.locator('.nodrag.nopan'); await expect(layerBoxes.first()).toBeVisible({ timeout: 10_000 }); // ── 5. Verify both input layers exist ───────────────────────────────── - const inputLayer0 = compositorNode - .getByText("Input 0", { exact: true }) - .first(); - const inputLayer1 = compositorNode - .getByText("Input 1", { exact: true }) - .first(); + const inputLayer0 = compositorNode.getByText('Input 0', { exact: true }).first(); + const inputLayer1 = compositorNode.getByText('Input 1', { exact: true }).first(); await expect(inputLayer0).toBeVisible({ timeout: 5_000 }); await expect(inputLayer1).toBeVisible({ timeout: 5_000 }); @@ -131,22 +119,22 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { await inputLayer1.click(); // Wait for inspector to render (slider becomes visible). - await expect(compositorNode.getByRole("slider").first()).toBeVisible({ + await expect(compositorNode.getByRole('slider').first()).toBeVisible({ timeout: 5_000, }); // Opacity section should be visible. const opacitySection = compositorNode - .locator("div") + .locator('div') .filter({ hasText: /^Opacity/ }) - .filter({ has: page.getByRole("slider") }) + .filter({ has: page.getByRole('slider') }) .first(); await expect(opacitySection).toBeVisible({ timeout: 5_000 }); // ── 7. Switch to Input 0 — verify it also works ─────────────────────── await inputLayer0.click(); - await expect(compositorNode.getByRole("slider").first()).toBeVisible({ + await expect(compositorNode.getByRole('slider').first()).toBeVisible({ timeout: 5_000, }); @@ -159,18 +147,15 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { // ── 8. Verify other pipeline nodes are present ──────────────────────── // The pipeline should have pixel_convert, vp9_encoder, and moq_peer nodes. - const allNodes = page.locator(".react-flow__node"); + const allNodes = page.locator('.react-flow__node'); const nodeCount = await allNodes.count(); - expect( - nodeCount, - "Pipeline should have multiple nodes", - ).toBeGreaterThanOrEqual(4); + expect(nodeCount, 'Pipeline should have multiple nodes').toBeGreaterThanOrEqual(4); // ── 9. Console error check ──────────────────────────────────────────── const unexpected = collector.getUnexpected(MOQ_BENIGN_PATTERNS); if (unexpected.length > 0) { - console.warn("Unexpected console errors (non-fatal):", unexpected); + console.warn('Unexpected console errors (non-fatal):', unexpected); } }); @@ -186,7 +171,7 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { // TuneNodeAsync → engine → compositor → pipeline API reflects the change. // --------------------------------------------------------------------------- - test("compositor param change from UI is reflected in server-side pipeline state", async ({ + test('compositor param change from UI is reflected in server-side pipeline state', async ({ page, baseURL, }) => { @@ -200,7 +185,7 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { }); const sessionName = `compositor-param-sync-${Date.now()}`; - const createResponse = await apiContext.post("/api/v1/sessions", { + const createResponse = await apiContext.post('/api/v1/sessions', { data: { name: sessionName, yaml: COMPOSITOR_COLORBARS_YAML, @@ -208,10 +193,7 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { }); const responseText = await createResponse.text(); - expect( - createResponse.ok(), - `Failed to create session: ${responseText}`, - ).toBeTruthy(); + expect(createResponse.ok(), `Failed to create session: ${responseText}`).toBeTruthy(); const createData = JSON.parse(responseText) as { session_id: string }; sessionId = createData.session_id; @@ -219,56 +201,49 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { // ── 2. Navigate to monitor and open the session ───────────────────── - await page.goto("/monitor"); + await page.goto('/monitor'); await ensureLoggedIn(page); - if (!page.url().includes("/monitor")) { - await page.goto("/monitor"); + if (!page.url().includes('/monitor')) { + await page.goto('/monitor'); } - await expect(page.getByTestId("monitor-view")).toBeVisible({ + await expect(page.getByTestId('monitor-view')).toBeVisible({ timeout: 15_000, }); - await expect(page.getByTestId("sessions-list")).toBeVisible({ + await expect(page.getByTestId('sessions-list')).toBeVisible({ timeout: 10_000, }); - const sessionItem = page - .getByTestId("session-item") - .filter({ hasText: sessionName }) - .first(); + const sessionItem = page.getByTestId('session-item').filter({ hasText: sessionName }).first(); await expect(sessionItem).toBeVisible({ timeout: 10_000 }); await sessionItem.click(); // ── 3. Wait for compositor node LIVE ───────────────────────────────── - const compositorNode = page - .locator(".react-flow__node") - .filter({ hasText: "Compositor" }); + const compositorNode = page.locator('.react-flow__node').filter({ hasText: 'Compositor' }); await expect(compositorNode).toBeVisible({ timeout: 15_000 }); - await expect(compositorNode.getByText("LIVE")).toBeVisible({ + await expect(compositorNode.getByText('LIVE')).toBeVisible({ timeout: 10_000, }); // ── 4. Select Input 1 and locate opacity slider ───────────────────── - const inputLayer1 = compositorNode - .getByText("Input 1", { exact: true }) - .first(); + const inputLayer1 = compositorNode.getByText('Input 1', { exact: true }).first(); await expect(inputLayer1).toBeVisible({ timeout: 5_000 }); await inputLayer1.click(); const opacitySection = compositorNode - .locator("div") + .locator('div') .filter({ hasText: /^Opacity/ }) - .filter({ has: page.getByRole("slider") }) + .filter({ has: page.getByRole('slider') }) .first(); await expect(opacitySection).toBeVisible({ timeout: 5_000 }); // ── 5. Drag opacity slider to change value ────────────────────────── - const thumb = opacitySection.getByRole("slider"); - await thumb.waitFor({ state: "visible", timeout: 5_000 }); + const thumb = opacitySection.getByRole('slider'); + await thumb.waitFor({ state: 'visible', timeout: 5_000 }); const box = await thumb.boundingBox(); - expect(box, "Opacity slider thumb must have a bounding box").toBeTruthy(); + expect(box, 'Opacity slider thumb must have a bounding box').toBeTruthy(); // Drag the slider significantly to the left to reduce opacity. const startX = box!.x + box!.width / 2; @@ -286,39 +261,29 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { // ── 6. Verify server-side pipeline state reflects the change ──────── - const pipelineResponse = await apiContext.get( - `/api/v1/sessions/${sessionId}/pipeline`, - ); - expect(pipelineResponse.ok(), "Pipeline API should return OK").toBeTruthy(); + const pipelineResponse = await apiContext.get(`/api/v1/sessions/${sessionId}/pipeline`); + expect(pipelineResponse.ok(), 'Pipeline API should return OK').toBeTruthy(); const pipeline = (await pipelineResponse.json()) as { nodes: Record }>; }; - const compositorParams = pipeline.nodes["compositor"]?.params; - expect( - compositorParams, - "Compositor node should have params in pipeline state", - ).toBeTruthy(); + const compositorParams = pipeline.nodes['compositor']?.params; + expect(compositorParams, 'Compositor node should have params in pipeline state').toBeTruthy(); // The layers object should exist and in_1's opacity should have // changed from the initial value of 0.9 (per the fixture YAML). - const layers = compositorParams!["layers"] as - | Record - | undefined; - expect(layers, "Compositor params should contain layers").toBeTruthy(); - expect( - layers!["in_1"], - "Layer in_1 should exist in compositor params", - ).toBeTruthy(); + const layers = compositorParams!['layers'] as Record | undefined; + expect(layers, 'Compositor params should contain layers').toBeTruthy(); + expect(layers!['in_1'], 'Layer in_1 should exist in compositor params').toBeTruthy(); - const newOpacity = layers!["in_1"]!.opacity; - expect(newOpacity, "in_1 opacity should be defined").toBeDefined(); + const newOpacity = layers!['in_1']!.opacity; + expect(newOpacity, 'in_1 opacity should be defined').toBeDefined(); expect( newOpacity, `in_1 opacity should have changed from initial 0.9 (got ${newOpacity}). ` + - "If still 0.9, the UI param change did not reach the server — " + - "likely UpdateParams deserialization is failing (e.g. _rev/_sender not stripped).", + 'If still 0.9, the UI param change did not reach the server — ' + + 'likely UpdateParams deserialization is failing (e.g. _rev/_sender not stripped).' ).not.toBeCloseTo(0.9, 1); await apiContext.dispose(); @@ -327,7 +292,7 @@ test.describe("Compositor Video Output — Two Colorbars Pipeline", () => { const unexpected = collector.getUnexpected(MOQ_BENIGN_PATTERNS); if (unexpected.length > 0) { - console.warn("Unexpected console errors (non-fatal):", unexpected); + console.warn('Unexpected console errors (non-fatal):', unexpected); } });