diff --git a/src/app/src/update/device/network/form.rs b/src/app/src/update/device/network/form.rs index 5a1aed5..2ca0753 100644 --- a/src/app/src/update/device/network/form.rs +++ b/src/app/src/update/device/network/form.rs @@ -51,6 +51,14 @@ pub fn handle_network_form_update( .. } = &model.network_form_state { + // Validate that this update is for the adapter currently being edited + // This protects against hidden Shell components (like v-window-items in Vue) + // sending updates for non-active adapters + if adapter_name != &form_data.name { + // Silently ignore updates from non-active adapters + return crux_core::render::render(); + } + let is_dirty = form_data != *original_data; // Compute rollback modal flags @@ -250,6 +258,55 @@ mod tests { } assert!(!model.network_form_dirty); } + + #[test] + fn ignores_updates_from_non_active_adapter() { + // Setup: editing eth0, but receive update for wlan0 + let eth0_data = NetworkFormData { + name: "eth0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec!["8.8.8.8".to_string()], + gateways: vec!["192.168.1.1".to_string()], + }; + + let wlan0_data = NetworkFormData { + name: "wlan0".to_string(), + ip_address: "192.168.2.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec!["8.8.8.8".to_string()], + gateways: vec!["192.168.2.1".to_string()], + }; + + let mut model = Model { + network_form_state: NetworkFormState::Editing { + adapter_name: "eth0".to_string(), + form_data: eth0_data.clone(), + original_data: eth0_data.clone(), + }, + network_form_dirty: false, + ..Default::default() + }; + + // Send update for wlan0 while eth0 is being edited + let _ = + handle_network_form_update(serde_json::to_string(&wlan0_data).unwrap(), &mut model); + + // State should remain unchanged + if let NetworkFormState::Editing { + adapter_name, + form_data, + .. + } = &model.network_form_state + { + assert_eq!(adapter_name, "eth0"); + assert_eq!(form_data.ip_address, "192.168.1.100"); + assert_eq!(form_data.name, "eth0"); + } + assert!(!model.network_form_dirty); + } } mod rollback_modal_flags { diff --git a/src/app/src/update/device/network/verification.rs b/src/app/src/update/device/network/verification.rs index 554f75c..fda8085 100644 --- a/src/app/src/update/device/network/verification.rs +++ b/src/app/src/update/device/network/verification.rs @@ -194,7 +194,6 @@ mod tests { use super::*; #[test] - #[ignore] fn tick_increments_attempt_counter() { let mut model = Model { network_change_state: NetworkChangeState::WaitingForNewIp { @@ -210,9 +209,12 @@ mod tests { let _ = handle_new_ip_check_tick(&mut model); + // Verify attempt counter was incremented if let NetworkChangeState::WaitingForNewIp { attempt, .. } = model.network_change_state { assert_eq!(attempt, 1); + } else { + panic!("Expected WaitingForNewIp state"); } } diff --git a/src/ui/src/components/network/DeviceNetworks.vue b/src/ui/src/components/network/DeviceNetworks.vue index ee83015..5ecf2b9 100644 --- a/src/ui/src/components/network/DeviceNetworks.vue +++ b/src/ui/src/components/network/DeviceNetworks.vue @@ -4,7 +4,7 @@ import NetworkSettings from "./NetworkSettings.vue" import { useCore } from "../../composables/useCore" import { useCoreInitialization } from "../../composables/useCoreInitialization" -const { viewModel, networkFormReset } = useCore() +const { viewModel, networkFormReset, networkFormStartEdit } = useCore() useCoreInitialization() @@ -38,6 +38,9 @@ watch(tab, (newTab, oldTab) => { // Revert tab back to old tab isReverting.value = true tab.value = oldTab + } else if (newTab) { + // Tab change successful - notify Core to start editing this adapter + networkFormStartEdit(newTab as string) } }) @@ -54,6 +57,10 @@ const confirmTabChange = () => { // Now switch to the pending tab tab.value = pendingTab.value + + // Start editing the new adapter + networkFormStartEdit(pendingTab.value) + pendingTab.value = null } showUnsavedChangesDialog.value = false @@ -77,7 +84,7 @@ const cancelTabChange = () => { :value="networkAdapter.name"> - + diff --git a/src/ui/src/components/network/NetworkSettings.vue b/src/ui/src/components/network/NetworkSettings.vue index a41e95e..6ddebb4 100644 --- a/src/ui/src/components/network/NetworkSettings.vue +++ b/src/ui/src/components/network/NetworkSettings.vue @@ -8,7 +8,7 @@ import type { DeviceNetwork } from "../../types" import type { NetworkConfigRequest } from "../../composables/useCore" const { showError } = useSnackbar() -const { viewModel, setNetworkConfig, networkFormReset, networkFormUpdate, networkFormStartEdit } = useCore() +const { viewModel, setNetworkConfig, networkFormReset, networkFormUpdate } = useCore() const { copy } = useClipboard() const { isValidIp: validateIp, parseNetmask } = useIPValidation() @@ -28,10 +28,10 @@ const isSubmitting = ref(false) const isSyncingFromWebSocket = ref(false) const isStartingFreshEdit = ref(false) -// Initialize form editing state in Core when component mounts +// NOTE: NetworkFormStartEdit is now called by the parent DeviceNetworks.vue when tab changes +// This prevents all mounted components from calling it simultaneously // Set flag to prevent the dirty flag watch from resetting form during initialization isStartingFreshEdit.value = true -networkFormStartEdit(props.networkAdapter.name) nextTick(() => { nextTick(() => { isStartingFreshEdit.value = false @@ -64,6 +64,8 @@ const sendFormUpdateToCore = () => { // Use flush: 'post' to ensure watcher runs after all DOM updates watch([ipAddress, dns, gateways, addressAssignment, netmask], () => { // Don't update dirty flag during submit or WebSocket sync + // Note: Core validates adapter_name matches the currently editing adapter + // This defends against hidden components (v-window) sending stale data if (!isSubmitting.value && !isSyncingFromWebSocket.value) { sendFormUpdateToCore() } @@ -100,25 +102,30 @@ watch(() => viewModel.network_form_dirty, (isDirty, wasDirty) => { }) // Watch for prop changes from WebSocket updates and sync local state +// IMPORTANT: We watch the entire adapter object to ensure reactivity, +// but only reset form fields if not dirty. This allows props.networkAdapter.online +// to remain reactive even when the form is dirty. watch(() => props.networkAdapter, (newAdapter) => { if (!newAdapter) return - // Don't overwrite user's unsaved changes during submit or when user has made edits - if (isSubmitting.value || viewModel.network_form_dirty) { - return - } - - // Set flag to prevent form watchers from firing during sync - isSyncingFromWebSocket.value = true - resetFormFields() + // Only reset form fields if user hasn't made unsaved changes + // But we still need to let this watcher run to maintain reactivity for + // non-form props like 'online' status + if (!isSubmitting.value && !viewModel.network_form_dirty) { + // Set flag to prevent form watchers from firing during sync + isSyncingFromWebSocket.value = true + resetFormFields() - // Clear flag after Vue finishes all reactive updates AND all post-flush watchers - // Need double nextTick: first for reactive updates, second for post-flush watchers - nextTick(() => { + // Clear flag after Vue finishes all reactive updates AND all post-flush watchers + // Need double nextTick: first for reactive updates, second for post-flush watchers nextTick(() => { - isSyncingFromWebSocket.value = false + nextTick(() => { + isSyncingFromWebSocket.value = false + }) }) - }) + } + // Note: We don't return early when dirty - this allows Vue's reactivity + // system to track changes to props.networkAdapter.online and other non-form props }, { deep: true }) const isDHCP = computed(() => addressAssignment.value === "dhcp") diff --git a/src/ui/tests/network-config-comprehensive.spec.ts b/src/ui/tests/network-configuration.spec.ts similarity index 58% rename from src/ui/tests/network-config-comprehensive.spec.ts rename to src/ui/tests/network-configuration.spec.ts index 97ed17b..988ad34 100644 --- a/src/ui/tests/network-config-comprehensive.spec.ts +++ b/src/ui/tests/network-configuration.spec.ts @@ -238,6 +238,343 @@ test.describe('Network Configuration - Comprehensive E2E Tests', () => { }); }); + test.describe('CRITICAL: Rollback Persistence and State', () => { + test('rollback status is cleared after ack and does not reappear on re-login', async ({ page }) => { + let healthcheckRollbackStatus = true; + + await page.unroute('**/healthcheck'); + await page.route('**/healthcheck', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + version_info: { required: '>=0.39.0', current: '0.40.0', mismatch: false }, + update_validation_status: { status: 'valid' }, + network_rollback_occurred: healthcheckRollbackStatus, + }), + }); + }); + + await page.route('**/ack-rollback', async (route) => { + if (route.request().method() === 'POST') { + healthcheckRollbackStatus = false; + await route.fulfill({ status: 200 }); + } + }); + + await page.goto('/'); + await expect(page.getByText('Network Settings Rolled Back')).toBeVisible({ timeout: 10000 }); + + await page.getByRole('button', { name: /ok/i }).click(); + await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible(); + await page.waitForTimeout(500); + + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + + await harness.setup(page, { + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }); + + await page.reload(); + await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible({ timeout: 3000 }); + + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible(); + + await harness.navigateToNetwork(page); + await harness.navigateToAdapter(page, 'eth0'); + await expect(page.getByText('eth0')).toBeVisible(); + }); + + test('Static -> DHCP: Rollback should be DISABLED by default', async ({ page }) => { + await harness.setup(page, { ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } }); + await expect(page.getByLabel('Static')).toBeChecked(); + + await page.getByLabel('DHCP').click({ force: true }); + await page.getByRole('button', { name: /save/i }).click(); + + await expect(page.getByText('Confirm Network Configuration Change')).toBeVisible(); + await expect(page.getByRole('checkbox', { name: /Enable automatic rollback/i })).not.toBeChecked(); + }); + + test('DHCP -> Static: Rollback should be ENABLED by default', async ({ page }) => { + await harness.setup(page, { ipv4: { addrs: [{ addr: 'localhost', dhcp: true, prefix_len: 24 }] } }); + await expect(page.getByLabel('DHCP')).toBeChecked(); + + await page.getByLabel('Static').click({ force: true }); + await page.getByRole('textbox', { name: /IP Address/i }).fill('192.168.1.150'); + await page.getByRole('button', { name: /save/i }).click(); + + await expect(page.getByText('Confirm Network Configuration Change')).toBeVisible(); + await expect(page.getByRole('checkbox', { name: /Enable automatic rollback/i })).toBeChecked(); + }); + + test('DHCP -> Static (Same IP): Rollback should be ENABLED', async ({ page }) => { + await harness.setup(page, { ipv4: { addrs: [{ addr: 'localhost', dhcp: true, prefix_len: 24 }] } }); + await expect(page.getByLabel('DHCP')).toBeChecked(); + + await page.getByLabel('Static').click({ force: true }); + // IP is auto-filled with current IP ('localhost'), do NOT change it. + await page.getByRole('button', { name: /save/i }).click(); + + // Verify Modal + await expect(page.getByText('Confirm Network Configuration Change')).toBeVisible(); + + // Verify Checkbox is CHECKED + await expect(page.getByRole('checkbox', { name: /Enable automatic rollback/i })).toBeChecked(); + + // Apply changes + await page.getByRole('button', { name: /apply changes/i }).click(); + + // Verify overlay appears with countdown label + await expect(page.locator('#overlay').getByText('Automatic rollback in:')).toBeVisible({ timeout: 10000 }); + }); + + test('Rollback should show MODAL not SNACKBAR when connection is restored at old IP', async ({ page }) => { + await harness.setup(page, { ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } }); + + await page.getByLabel('DHCP').click({ force: true }); + + // Mock /network to return a short rollback timeout for testing + await page.unroute('**/network'); + await harness.mockNetworkConfig(page, { rollbackTimeoutSeconds: 2 }); + + await page.getByRole('button', { name: /save/i }).click(); + await expect(page.getByText('Confirm Network Configuration Change')).toBeVisible(); + await page.getByRole('checkbox', { name: /Enable automatic rollback/i }).check(); + + // Override healthcheck mock to return network_rollback_occurred: true + await page.unroute('**/healthcheck'); + await page.route('**/healthcheck', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + version_info: { required: '>=0.39.0', current: '0.40.0', mismatch: false }, + update_validation_status: { status: 'valid' }, + network_rollback_occurred: true, + }), + }); + }); + + await page.getByRole('button', { name: /Apply Changes/i }).click(); + await expect(page.getByText('Applying network settings')).toBeVisible(); + + await expect(page.getByText('Automatic network rollback successful')).not.toBeVisible(); + await expect(page.getByText('The network settings were rolled back to the previous configuration')).toBeVisible({ timeout: 10000 }); + }); + + test('Rollback modal should close on second apply after a rollback', async ({ page }) => { + test.setTimeout(60000); // Increase timeout for rollback scenario + + // Shim WebSocket to redirect 192.168.1.150 to localhost + await page.addInitScript(() => { + const OriginalWebSocket = window.WebSocket; + // @ts-ignore + window.WebSocket = function(url, protocols) { + if (typeof url === 'string' && url.includes('192.168.1.150')) { + console.log(`[Shim] Redirecting WebSocket ${url} to localhost`); + url = url.replace('192.168.1.150', 'localhost'); + } + return new OriginalWebSocket(url, protocols); + }; + window.WebSocket.prototype = OriginalWebSocket.prototype; + window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING; + window.WebSocket.OPEN = OriginalWebSocket.OPEN; + window.WebSocket.CLOSING = OriginalWebSocket.CLOSING; + window.WebSocket.CLOSED = OriginalWebSocket.CLOSED; + }); + + // Mock the redirect to the new IP to prevent navigation failure + await page.route(/.*192\.168\.1\.150.*/, async (route) => { + const url = new URL(route.request().url()); + + // Handle healthcheck separately to avoid CORS and ensure correct response + if (url.pathname.includes('/healthcheck')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { + 'Access-Control-Allow-Origin': 'https://localhost:5173', + 'Access-Control-Allow-Credentials': 'true', + }, + body: JSON.stringify({ + version_info: { required: '>=0.39.0', current: '0.40.0', mismatch: false }, + update_validation_status: { status: 'valid' }, + network_rollback_occurred: harness.getRollbackState().occurred, + }), + }); + return; + } + + // For other requests (main page, assets), proxy to localhost + const originalUrl = page.url(); + const originalPort = new URL(originalUrl).port || '5173'; + const originalProtocol = new URL(originalUrl).protocol; + + const newUrl = `${originalProtocol}//localhost:${originalPort}${url.pathname}${url.search}`; + + console.log(`Redirecting ${url.href} to ${newUrl}`); + + try { + const response = await page.request.fetch(newUrl, { + method: route.request().method(), + headers: route.request().headers(), + data: route.request().postDataBuffer(), + ignoreHTTPSErrors: true + }); + + await route.fulfill({ + response: response + }); + } catch (e) { + console.error('Failed to proxy request:', e); + await route.abort(); + } + }); + + await page.unroute('**/network'); + await harness.mockNetworkConfig(page, { rollbackTimeoutSeconds: 10 }); // Short timeout + + // Set browser hostname to match the harness default IP (192.168.1.100) + // so Core detects it as current connection + await page.evaluate(() => { + // @ts-ignore + if (window.setBrowserHostname) { + // @ts-ignore + window.setBrowserHostname('192.168.1.100'); + } + }); + + // 1. Setup adapter with static IP (current connection) + // Using 192.168.1.100 to match harness default currentIp + await harness.setup(page, { + ipv4: { + addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }); + + // 2. First Change - will trigger rollback + const ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await ipInput.fill('192.168.1.150'); + + await page.getByRole('button', { name: /save/i }).click(); + + const confirmDialog = page.getByText('Confirm Network Configuration Change'); + await expect(confirmDialog).toBeVisible(); + + const rollbackCheckbox = page.getByRole('checkbox', { name: /Enable automatic rollback/i }); + if (!(await rollbackCheckbox.isChecked())) { + await rollbackCheckbox.check(); + } + + await page.getByRole('button', { name: /apply changes/i }).click(); + + // Verify modal closed + await expect(confirmDialog).not.toBeVisible(); + + // Verify rollback overlay appears + await expect(page.locator('#overlay').getByText('Automatic rollback in:')).toBeVisible(); + + // Wait for at least one healthcheck attempt on the new IP + await page.waitForResponse(resp => resp.url().includes('192.168.1.150') && resp.url().includes('healthcheck')); + + // 3. Simulate Rollback Timeout + await harness.simulateRollbackTimeout(); + + // UI should eventually show "Network Settings Rolled Back" + await expect(page.getByText('Network Settings Rolled Back')).toBeVisible({ timeout: 30000 }); + + // 4. Acknowledge Rollback + await page.getByRole('button', { name: /ok/i }).click(); + + // Verify rollback dialog is fully closed + await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible(); + + // Wait for the ack-rollback API call to complete and backend to process it + // This ensures the rollback flag is cleared before we reload + await page.waitForTimeout(2000); + + // Navigate to localhost to avoid the 192.168.1.150 route and reload cleanly + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + // Wait for the login page to be ready + await page.waitForTimeout(1000); + + // Dismiss rollback dialog if it appears again (backend hasn't cleared flag yet) + const rollbackDialog = page.getByText('Network Settings Rolled Back'); + if (await rollbackDialog.isVisible()) { + await page.getByRole('button', { name: /ok/i }).click(); + await expect(rollbackDialog).not.toBeVisible(); + await page.waitForTimeout(500); + } + + // Re-login after navigation + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 15000 }); + + // Navigate to the network page + await harness.navigateToNetwork(page); + await harness.navigateToAdapter(page, 'eth0'); + + // Wait for the Core to fully initialize and load network status + // After reload, it takes a moment for Centrifugo to reconnect and publish status + await page.waitForTimeout(2000); + + // Set browser hostname again after reload to mark eth0 as current connection + await page.evaluate(() => { + // @ts-ignore + if (window.setBrowserHostname) { + // @ts-ignore + window.setBrowserHostname('192.168.1.100'); + } + }); + + // Wait for Core to process the hostname change + await page.waitForTimeout(500); + + // After rollback, IP should be back to the original value (192.168.1.100) + const currentIpInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(currentIpInput).toHaveValue('192.168.1.100'); + + // 5. Second Change - try again with a different IP + await currentIpInput.clear(); + await currentIpInput.fill('192.168.1.151'); + + // Wait for form to recognize the change + await page.waitForTimeout(500); + + await page.getByRole('button', { name: /save/i }).click(); + + // Verify confirmation dialog appears for the second change + const confirmDialog2 = page.getByText('Confirm Network Configuration Change'); + await expect(confirmDialog2).toBeVisible(); + + // Ensure rollback is checked + const rollbackCheckbox2 = page.getByRole('checkbox', { name: /Enable automatic rollback/i }); + if (!(await rollbackCheckbox2.isChecked())) { + await rollbackCheckbox2.check(); + } + + await page.getByRole('button', { name: /apply changes/i }).click(); + + // 6. Verify modal closed (second time) + await expect(confirmDialog2).not.toBeVisible({ timeout: 5000 }); + }); + }); + test.describe('HIGH: Basic Configuration Workflows', () => { test('static IP on non-server adapter - no rollback modal', async ({ page }) => { await harness.setup(page, { @@ -547,6 +884,80 @@ test.describe('Network Configuration - Comprehensive E2E Tests', () => { await expect(ipInput).toBeEditable(); }); + test('REGRESSION: adapter status updates from online to offline via WebSocket', async ({ page }) => { + // Setup adapter initially online + await harness.setup(page, { + online: true, + ipv4: { + addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }); + + // Verify adapter is online + await expect(page.locator('.v-chip').filter({ hasText: 'Online' })).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.v-chip').filter({ hasText: 'Offline' })).not.toBeVisible(); + + // Simulate network cable removal - adapter goes offline via WebSocket update + await harness.publishNetworkStatus([{ + name: 'eth0', + mac: '00:11:22:33:44:55', + online: false, // Changed from true to false + ipv4: { + addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }]); + + // Verify UI updates to show offline status + await expect(page.locator('.v-chip').filter({ hasText: 'Offline' })).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.v-chip').filter({ hasText: 'Online' })).not.toBeVisible(); + }); + + test('REGRESSION: adapter status updates while editing form', async ({ page }) => { + // Setup adapter initially online + await harness.setup(page, { + online: true, + ipv4: { + addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }); + + // Verify adapter is online + await expect(page.locator('.v-chip').filter({ hasText: 'Online' })).toBeVisible({ timeout: 5000 }); + + // Start editing the form to make it dirty + const ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await ipInput.fill('192.168.1.101'); + await page.waitForTimeout(500); // Let dirty flag propagate + + // Verify form is dirty + await expect(page.getByRole('button', { name: /reset/i })).toBeEnabled(); + + // While editing, simulate network cable removal - adapter goes offline + await harness.publishNetworkStatus([{ + name: 'eth0', + mac: '00:11:22:33:44:55', + online: false, // Changed from true to false + ipv4: { + addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }]); + + // Verify UI updates to show offline status even while form is dirty + await expect(page.locator('.v-chip').filter({ hasText: 'Offline' })).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.v-chip').filter({ hasText: 'Online' })).not.toBeVisible(); + + // Verify edited IP is preserved (dirty flag prevents overwrite of form fields) + await expect(ipInput).toHaveValue('192.168.1.101'); + }); + test('WebSocket sync during editing - dirty flag prevents overwrite', async ({ page }) => { await harness.setup(page, { ipv4: { diff --git a/src/ui/tests/network-multi-adapter.spec.ts b/src/ui/tests/network-multi-adapter.spec.ts new file mode 100644 index 0000000..870817f --- /dev/null +++ b/src/ui/tests/network-multi-adapter.spec.ts @@ -0,0 +1,777 @@ +import { test, expect } from '@playwright/test'; +import { mockConfig, mockLoginSuccess, mockRequireSetPassword } from './fixtures/mock-api'; +import { NetworkTestHarness } from './fixtures/network-test-harness'; + +test.describe('Network Multi-Adapter Rollback Modal', () => { + let harness: NetworkTestHarness; + + test.beforeEach(async ({ page }) => { + harness = new NetworkTestHarness(); + await mockConfig(page); + await mockLoginSuccess(page); + await mockRequireSetPassword(page); + await harness.mockNetworkConfig(page); + await harness.mockHealthcheck(page); + await harness.mockAckRollback(page); + + await page.goto('/'); + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible(); + }); + + test.afterEach(() => { + harness.reset(); + }); + + test.describe('2-Adapter Scenarios', () => { + test('REGRESSION: rollback modal only appears for current connection adapter', async ({ page }) => { + // Setup two adapters: eth0 (current connection) and wlan0 (not current) + await harness.setup(page, [ + { + name: 'eth0', + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'] + } + }, + { + name: 'wlan0', + ipv4: { + addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'] + } + } + ]); + + // Part 1: Test current connection adapter (eth0) - SHOULD show rollback modal + await page.getByRole('tab', { name: 'eth0' }).click(); + await expect(page.getByText('(current connection)')).toBeVisible(); + + const eth0IpInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(eth0IpInput).toHaveValue('localhost'); + + await eth0IpInput.fill('192.168.1.150'); + await page.waitForTimeout(300); + + await page.getByRole('button', { name: /save/i }).click(); + + // Rollback modal SHOULD appear for current connection adapter + const rollbackModal = page.getByText('Confirm Network Configuration Change'); + await expect(rollbackModal).toBeVisible({ timeout: 3000 }); + + // Cancel the modal + await page.getByRole('button', { name: /cancel/i }).click(); + await expect(rollbackModal).not.toBeVisible(); + + // Reset the form + await page.getByRole('button', { name: /reset/i }).click(); + await page.waitForTimeout(300); + + // Part 2: Test non-current adapter (wlan0) - should NOT show rollback modal + await page.getByRole('tab', { name: 'wlan0' }).click(); + await expect(page.getByText('(current connection)')).not.toBeVisible(); + + // Wait for tab to fully activate and NetworkFormStartEdit to be called + await page.waitForTimeout(500); + + // Trigger a WebSocket update to eth0 (the hidden adapter) to reproduce the bug + // This simulates what happens in production when network status changes + await harness.publishNetworkStatus([ + { + name: 'eth0', + mac: '00:11:22:33:44:55', + online: false, // Changed from true to false + ipv4: { + addrs: [], // Empty because offline + dns: [], + gateways: [] + } + }, + { + name: 'wlan0', + mac: '00:11:22:33:44:56', + online: true, + ipv4: { + addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'] + } + } + ]); + + // Wait for WebSocket update to propagate + await page.waitForTimeout(300); + + const wlan0IpInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(wlan0IpInput).toHaveValue('192.168.2.100'); + + // Clear and fill with new value + await wlan0IpInput.click(); + await wlan0IpInput.clear(); + await wlan0IpInput.fill('192.168.2.150'); + await page.waitForTimeout(500); // Give watchers time to update dirty flag + + // Verify the value was actually set + await expect(wlan0IpInput).toHaveValue('192.168.2.150'); + + const saveButton = page.getByRole('button', { name: /save/i }); + await saveButton.click(); + + // Rollback modal should NOT appear for non-current adapter + // This is the CRITICAL assertion - if this fails, the bug has regressed + // BUG: Without the fix, eth0's hidden component sends NetworkFormUpdate with eth0's data, + // causing Core to think eth0 is being edited, which triggers the rollback modal + await expect(rollbackModal).not.toBeVisible({ timeout: 2000 }); + + // Should proceed directly to saving (no rollback modal blocking it) + await expect(saveButton).toBeEnabled({ timeout: 10000 }); + await expect(page.getByText('Network configuration updated')).toBeVisible(); + }); + + test('switching between current and non-current adapters preserves form state', async ({ page }) => { + await harness.setup(page, [ + { + name: 'eth0', + ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } + }, + { + name: 'wlan0', + ipv4: { addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }] } + } + ]); + + // Make unsaved changes on eth0 (current connection) + await page.getByRole('tab', { name: 'eth0' }).click(); + const eth0IpInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await eth0IpInput.fill('192.168.1.200'); + await page.waitForTimeout(300); + + // Switch to wlan0 - should trigger "Unsaved Changes" dialog + await page.getByRole('tab', { name: 'wlan0' }).click(); + await expect(page.getByText('Unsaved Changes', { exact: true })).toBeVisible(); + + // Cancel and stay on eth0 + await page.getByRole('button', { name: /cancel/i }).click(); + await page.waitForTimeout(300); + + // Verify we're still on eth0 with unsaved changes + await expect(eth0IpInput).toHaveValue('192.168.1.200'); + + // Now discard and switch + await page.getByRole('tab', { name: 'wlan0' }).click(); + await expect(page.getByText('Unsaved Changes', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: /discard/i }).click(); + await page.waitForTimeout(300); + + // Verify we switched to wlan0 + const wlan0IpInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(wlan0IpInput).toHaveValue('192.168.2.100'); + }); + }); + + test.describe('3-Adapter Scenarios', () => { + test('rollback modal behavior with three adapters', async ({ page }) => { + // Setup three adapters with different network configurations + await harness.setup(page, [ + { + name: 'eth0', + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'] + } + }, + { + name: 'wlan0', + ipv4: { + addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'] + } + }, + { + name: 'eth1', + mac: '00:11:22:33:44:56', + ipv4: { + addrs: [{ addr: '10.0.0.50', dhcp: false, prefix_len: 24 }], + dns: ['1.1.1.1'], + gateways: ['10.0.0.1'] + } + } + ]); + + // Verify all three tabs are visible + await expect(page.getByRole('tab', { name: 'eth0' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'wlan0' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'eth1' })).toBeVisible(); + + const rollbackModal = page.getByText('Confirm Network Configuration Change'); + + // Test 1: eth0 (current connection) - SHOULD show rollback modal + await page.getByRole('tab', { name: 'eth0' }).click(); + await expect(page.getByText('(current connection)')).toBeVisible(); + + let ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await ipInput.fill('192.168.1.150'); + await page.waitForTimeout(300); + await page.getByRole('button', { name: /save/i }).click(); + + await expect(rollbackModal).toBeVisible({ timeout: 3000 }); + await page.getByRole('button', { name: /cancel/i }).click(); + await page.getByRole('button', { name: /reset/i }).click(); + await page.waitForTimeout(300); + + // Test 2: wlan0 (not current) - should NOT show rollback modal + await page.getByRole('tab', { name: 'wlan0' }).click(); + await expect(page.getByText('(current connection)')).not.toBeVisible(); + + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('192.168.2.100'); + await ipInput.click(); + await ipInput.clear(); + await ipInput.fill('192.168.2.150'); + await page.waitForTimeout(500); + await expect(ipInput).toHaveValue('192.168.2.150'); + + const saveButton = page.getByRole('button', { name: /save/i }); + await saveButton.click(); + + await expect(rollbackModal).not.toBeVisible({ timeout: 2000 }); + await expect(saveButton).toBeEnabled({ timeout: 10000 }); + await expect(page.getByText('Network configuration updated')).toBeVisible(); + + // Wait for form to reset after submission + await page.waitForTimeout(1000); + + // Test 3: eth1 (not current) - should NOT show rollback modal + await page.getByRole('tab', { name: 'eth1' }).click(); + await expect(page.getByText('(current connection)')).not.toBeVisible(); + + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('10.0.0.50'); + await ipInput.click(); + await ipInput.clear(); + await ipInput.fill('10.0.0.100'); + await page.waitForTimeout(500); + await expect(ipInput).toHaveValue('10.0.0.100'); + + const saveButton2 = page.getByRole('button', { name: /save/i }); + await saveButton2.click(); + + await expect(rollbackModal).not.toBeVisible({ timeout: 2000 }); + await expect(saveButton2).toBeEnabled({ timeout: 10000 }); + await expect(page.getByText('Network configuration updated')).toBeVisible(); + }); + + test('form state isolation across multiple adapter switches', async ({ page }) => { + await harness.setup(page, [ + { + name: 'eth0', + ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } + }, + { + name: 'wlan0', + ipv4: { addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }] } + }, + { + name: 'eth1', + mac: '00:11:22:33:44:56', + ipv4: { addrs: [{ addr: '10.0.0.50', dhcp: false, prefix_len: 24 }] } + } + ]); + + // Make unsaved changes on eth1 + await page.getByRole('tab', { name: 'eth1' }).click(); + + // Wait for NetworkFormStartEdit to be called and form to initialize + await page.waitForTimeout(500); + + let ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await ipInput.click(); + await ipInput.clear(); + await ipInput.fill('10.0.0.99'); + await page.waitForTimeout(500); // Wait for dirty flag to propagate + + // Switch to wlan0 - should show "Unsaved Changes" dialog + await page.getByRole('tab', { name: 'wlan0' }).click(); + await expect(page.getByText('Unsaved Changes', { exact: true })).toBeVisible(); + + // Discard and switch + await page.getByRole('button', { name: /discard/i }).click(); + await page.waitForTimeout(300); + + // Verify we're on wlan0 + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('192.168.2.100'); + + // Switch back to eth1 - form should be reset (changes were discarded) + await page.getByRole('tab', { name: 'eth1' }).click(); + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('10.0.0.50'); // Original value, not 10.0.0.99 + + // Make changes on eth0 (current connection) + await page.getByRole('tab', { name: 'eth0' }).click(); + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await ipInput.fill('192.168.1.88'); + await page.waitForTimeout(300); + + // Switch to eth1 + await page.getByRole('tab', { name: 'eth1' }).click(); + await expect(page.getByText('Unsaved Changes', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: /cancel/i }).click(); + + // Verify we're still on eth0 with unsaved changes + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('192.168.1.88'); + + // Now discard and verify each adapter has its correct original state + await page.getByRole('button', { name: /reset/i }).click(); + await page.waitForTimeout(300); + await expect(ipInput).toHaveValue('localhost'); + + await page.getByRole('tab', { name: 'wlan0' }).click(); + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('192.168.2.100'); + + await page.getByRole('tab', { name: 'eth1' }).click(); + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('10.0.0.50'); + }); + }); + + test.describe('Edge Cases', () => { + test('WebSocket update to non-current adapter preserves unsaved changes', async ({ page }) => { + // Setup: eth0 (current at localhost), wlan0 (at 192.168.2.100) + await harness.setup(page, [ + { + name: 'eth0', + ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } + }, + { + name: 'wlan0', + ipv4: { + addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'] + } + } + ]); + + // Navigate to wlan0 tab + await page.getByRole('tab', { name: 'wlan0' }).click(); + await page.waitForTimeout(300); + + const ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('192.168.2.100'); + + // User changes wlan0 IP to 192.168.2.150 (form becomes dirty) + await ipInput.click(); + await ipInput.clear(); + await ipInput.fill('192.168.2.150'); + await page.waitForTimeout(500); // Wait for dirty flag + + // Verify user's edit is in place + await expect(ipInput).toHaveValue('192.168.2.150'); + + // WebSocket publishes NetworkStatusUpdated with wlan0 IP = 192.168.2.200 + // This simulates a real-world scenario like DHCP renew or bridge formation + await harness.publishNetworkStatus([ + { + name: 'eth0', + mac: '00:11:22:33:44:55', + online: true, + ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } + }, + { + name: 'wlan0', + mac: '00:11:22:33:44:56', + online: true, + ipv4: { + addrs: [{ addr: '192.168.2.200', dhcp: false, prefix_len: 24 }], // Changed! + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'] + } + } + ]); + + await page.waitForTimeout(500); + + // EXPECTED: Form still shows 192.168.2.150 (user's edits preserved) + await expect(ipInput).toHaveValue('192.168.2.150'); + + // User can still save with their value + const saveButton = page.getByRole('button', { name: /save/i }); + await saveButton.click(); + await expect(saveButton).toBeEnabled({ timeout: 10000 }); + await expect(page.getByText('Network configuration updated')).toBeVisible(); + }); + + test('rollback modal blocks tab switching during submission', async ({ page }) => { + // Setup: eth0 (current), wlan0 (not current) + await harness.setup(page, [ + { + name: 'eth0', + ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } + }, + { + name: 'wlan0', + ipv4: { addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }] } + } + ]); + + // Edit eth0 IP + await page.getByRole('tab', { name: 'eth0' }).click(); + const ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await ipInput.fill('192.168.1.150'); + await page.waitForTimeout(300); + + // Click Save - rollback modal appears for current connection adapter + await page.getByRole('button', { name: /save/i }).click(); + + const rollbackModal = page.getByText('Confirm Network Configuration Change'); + await expect(rollbackModal).toBeVisible({ timeout: 3000 }); + + // Verify the modal overlay prevents tab switching + // The wlan0 tab should be present but the modal overlay blocks interaction + const wlan0Tab = page.getByRole('tab', { name: 'wlan0' }); + await expect(wlan0Tab).toBeVisible(); + + // Verify modal is still visible (hasn't been dismissed by attempting to click tab) + await expect(rollbackModal).toBeVisible(); + + // Cancel the modal + await page.getByRole('button', { name: /cancel/i }).click(); + await expect(rollbackModal).not.toBeVisible(); + + // Reset the form + await page.getByRole('button', { name: /reset/i }).click(); + await page.waitForTimeout(300); + + // Now tab switch should work normally (no modal blocking) + await wlan0Tab.click(); + const wlan0IpInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(wlan0IpInput).toHaveValue('192.168.2.100'); + }); + + test('only first adapter with matching IP is treated as current connection', async ({ page }) => { + // Setup: eth0 (localhost), wlan0 (localhost - duplicate!), eth1 (10.0.0.50) + await harness.setup(page, [ + { + name: 'eth0', + ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } + }, + { + name: 'wlan0', + ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } // Same IP! + }, + { + name: 'eth1', + mac: '00:11:22:33:44:56', + ipv4: { addrs: [{ addr: '10.0.0.50', dhcp: false, prefix_len: 24 }] } + } + ]); + + const rollbackModal = page.getByText('Confirm Network Configuration Change'); + + // Verify eth0 is marked as current connection + await page.getByRole('tab', { name: 'eth0' }).click(); + await expect(page.getByText('(current connection)')).toBeVisible(); + + // Edit eth0, verify rollback modal appears + let ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await ipInput.fill('192.168.1.150'); + await page.waitForTimeout(300); + await page.getByRole('button', { name: /save/i }).click(); + await expect(rollbackModal).toBeVisible({ timeout: 3000 }); + await page.getByRole('button', { name: /cancel/i }).click(); + await page.getByRole('button', { name: /reset/i }).click(); + await page.waitForTimeout(300); + + // Verify wlan0 is NOT marked as current connection (even though it has same IP) + await page.getByRole('tab', { name: 'wlan0' }).click(); + await expect(page.getByText('(current connection)')).not.toBeVisible(); + + // Edit wlan0, verify rollback modal does NOT appear + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('localhost'); + await ipInput.click(); + await ipInput.clear(); + await ipInput.fill('192.168.2.150'); + await page.waitForTimeout(500); + + const saveButton = page.getByRole('button', { name: /save/i }); + await saveButton.click(); + await expect(rollbackModal).not.toBeVisible({ timeout: 2000 }); + await expect(saveButton).toBeEnabled({ timeout: 10000 }); + await expect(page.getByText('Network configuration updated')).toBeVisible(); + + await page.waitForTimeout(1000); + + // Verify eth1 also does NOT show rollback modal + await page.getByRole('tab', { name: 'eth1' }).click(); + await expect(page.getByText('(current connection)')).not.toBeVisible(); + + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('10.0.0.50'); + await ipInput.click(); + await ipInput.clear(); + await ipInput.fill('10.0.0.100'); + await page.waitForTimeout(500); + + const saveButton2 = page.getByRole('button', { name: /save/i }); + await saveButton2.click(); + await expect(rollbackModal).not.toBeVisible({ timeout: 2000 }); + await expect(saveButton2).toBeEnabled({ timeout: 10000 }); + await expect(page.getByText('Network configuration updated')).toBeVisible(); + }); + + test('rapid tab switching with edits shows correct dirty state', async ({ page }) => { + // Setup: 3 adapters (eth0, wlan0, eth1) + await harness.setup(page, [ + { + name: 'eth0', + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'] + } + }, + { + name: 'wlan0', + ipv4: { + addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'] + } + }, + { + name: 'eth1', + mac: '00:11:22:33:44:56', + ipv4: { + addrs: [{ addr: '10.0.0.50', dhcp: false, prefix_len: 24 }], + dns: ['1.1.1.1'], + gateways: ['10.0.0.1'] + } + } + ]); + + // Edit eth0 IP (dirty = true) + await page.getByRole('tab', { name: 'eth0' }).click(); + let ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await ipInput.fill('192.168.1.99'); + await page.waitForTimeout(300); + + // Click wlan0 tab → unsaved changes dialog → cancel + await page.getByRole('tab', { name: 'wlan0' }).click(); + await expect(page.getByText('Unsaved Changes', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: /cancel/i }).click(); + await page.waitForTimeout(300); + + // Still on eth0, verify IP changed + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('192.168.1.99'); + + // Click wlan0 again → discard changes + await page.getByRole('tab', { name: 'wlan0' }).click(); + await expect(page.getByText('Unsaved Changes', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: /discard/i }).click(); + await page.waitForTimeout(300); + + // Now on wlan0, verify original IP + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('192.168.2.100'); + + // Edit wlan0 IP (dirty = true) + await ipInput.click(); + await ipInput.clear(); + await ipInput.fill('192.168.2.88'); + await page.waitForTimeout(500); + + // Click eth1 → discard + await page.getByRole('tab', { name: 'eth1' }).click(); + await expect(page.getByText('Unsaved Changes', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: /discard/i }).click(); + await page.waitForTimeout(300); + + // Verify eth1 shows original IP, dirty = false (no unsaved changes) + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('10.0.0.50'); + + // Switch back to eth0 - should show original IP (changes were discarded) + await page.getByRole('tab', { name: 'eth0' }).click(); + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('localhost'); // Original, not 192.168.1.99 + + // Switch back to wlan0 - should show original IP (changes were discarded) + await page.getByRole('tab', { name: 'wlan0' }).click(); + ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toHaveValue('192.168.2.100'); // Original, not 192.168.2.88 + }); + + test('REGRESSION: online status updates with multiple adapters', async ({ page }) => { + // Setup two adapters, both initially online + await harness.setup(page, [ + { + name: 'eth0', + online: true, + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }, + { + name: 'wlan0', + mac: '00:11:22:33:44:66', + online: true, + ipv4: { + addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'], + }, + }, + ]); + + // Verify eth0 is online + await expect(page.locator('.v-chip').filter({ hasText: 'Online' })).toBeVisible(); + + // Simulate eth0 going offline (cable removed) + await harness.publishNetworkStatus([ + { + name: 'eth0', + online: false, // Changed to offline + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }, + { + name: 'wlan0', + mac: '00:11:22:33:44:66', + online: true, // Still online + ipv4: { + addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'], + }, + }, + ]); + + // Verify eth0 now shows as offline + await expect(page.locator('.v-chip').filter({ hasText: 'Offline' })).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.v-chip').filter({ hasText: 'Online' })).not.toBeVisible(); + + // Switch to wlan0 - should still be online + await page.getByRole('tab', { name: 'wlan0' }).click(); + await page.waitForTimeout(500); + await expect(page.locator('.v-chip').filter({ hasText: 'Online' })).toBeVisible(); + await expect(page.locator('.v-chip').filter({ hasText: 'Offline' })).not.toBeVisible(); + }); + + test('REGRESSION: online status updates even with dirty form on multi-adapter', async ({ page }) => { + // Setup two adapters, both initially online + await harness.setup(page, [ + { + name: 'eth0', + online: true, + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }, + { + name: 'wlan0', + mac: '00:11:22:33:44:66', + online: true, + ipv4: { + addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'], + }, + }, + ]); + + // Verify eth0 is online + await expect(page.locator('.v-chip').filter({ hasText: 'Online' })).toBeVisible(); + + // Make form dirty by editing IP + const ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await ipInput.fill('192.168.1.150'); + await page.waitForTimeout(500); + + // Verify form is dirty + await expect(page.getByRole('button', { name: /reset/i })).toBeEnabled(); + + // While form is dirty, simulate eth0 going offline (cable removed) + await harness.publishNetworkStatus([ + { + name: 'eth0', + online: false, // Changed to offline + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }, + { + name: 'wlan0', + mac: '00:11:22:33:44:66', + online: true, // Still online + ipv4: { + addrs: [{ addr: '192.168.2.100', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.2.1'], + }, + }, + ]); + + // BUG: Online status should update even with dirty form + await expect(page.locator('.v-chip').filter({ hasText: 'Offline' })).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.v-chip').filter({ hasText: 'Online' })).not.toBeVisible(); + + // Verify edited IP is still preserved (dirty flag should prevent form field reset) + await expect(ipInput).toHaveValue('192.168.1.150'); + }); + + test('BUG REPRODUCTION: online chip does not update when adapter goes offline', async ({ page }) => { + // This test specifically checks the Online/Offline chip element by exact CSS classes + await harness.setup(page, { + online: true, + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }); + + // Find the specific chip by its color classes + const onlineChip = page.locator('.v-chip.text-light-green-darken-2').filter({ hasText: 'Online' }); + const offlineChip = page.locator('.v-chip.text-red-darken-2').filter({ hasText: 'Offline' }); + + // Verify chip shows Online initially with green color + await expect(onlineChip).toBeVisible(); + await expect(offlineChip).not.toBeVisible(); + + // Simulate cable removal - adapter goes offline + await harness.publishNetworkStatus([{ + name: 'eth0', + mac: '00:11:22:33:44:55', + online: false, + ipv4: { + addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }]); + + // BUG: The chip should update to show Offline with red color + await expect(offlineChip).toBeVisible({ timeout: 5000 }); + await expect(onlineChip).not.toBeVisible(); + }); + }); +}); diff --git a/src/ui/tests/network-rollback.spec.ts b/src/ui/tests/network-rollback.spec.ts deleted file mode 100644 index 4a7ac07..0000000 --- a/src/ui/tests/network-rollback.spec.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { mockConfig, mockLoginSuccess, mockRequireSetPassword } from './fixtures/mock-api'; -import { NetworkTestHarness } from './fixtures/network-test-harness'; - -test.describe('Network Rollback Status', () => { - let harness: NetworkTestHarness; - - test.beforeEach(async () => { - harness = new NetworkTestHarness(); - }); - - test.afterEach(() => { - harness.reset(); - }); - - test('rollback status is cleared after ack and does not reappear on re-login', async ({ page }) => { - let healthcheckRollbackStatus = true; - - await mockConfig(page); - await mockLoginSuccess(page); - await mockRequireSetPassword(page); - - await page.route('**/healthcheck', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - version_info: { required: '>=0.39.0', current: '0.40.0', mismatch: false }, - update_validation_status: { status: 'valid' }, - network_rollback_occurred: healthcheckRollbackStatus, - }), - }); - }); - - await page.route('**/ack-rollback', async (route) => { - if (route.request().method() === 'POST') { - healthcheckRollbackStatus = false; - await route.fulfill({ status: 200 }); - } - }); - - await page.goto('/'); - await expect(page.getByText('Network Settings Rolled Back')).toBeVisible({ timeout: 10000 }); - - await page.getByRole('button', { name: /ok/i }).click(); - await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible(); - await page.waitForTimeout(500); - - await page.getByPlaceholder(/enter your password/i).fill('password'); - await page.getByRole('button', { name: /log in/i }).click(); - await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); - - await harness.setup(page, { - ipv4: { - addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], - dns: ['8.8.8.8'], - gateways: ['192.168.1.1'], - }, - }); - - await page.reload(); - await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible({ timeout: 3000 }); - - await page.getByPlaceholder(/enter your password/i).fill('password'); - await page.getByRole('button', { name: /log in/i }).click(); - await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); - await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible(); - - await harness.navigateToNetwork(page); - await harness.navigateToAdapter(page, 'eth0'); - await expect(page.getByText('eth0')).toBeVisible(); - }); -}); - -test.describe('Network Rollback Defaults', () => { - let harness: NetworkTestHarness; - - test.beforeEach(async ({ page }) => { - harness = new NetworkTestHarness(); - await mockConfig(page); - await mockLoginSuccess(page); - await mockRequireSetPassword(page); - await harness.mockNetworkConfig(page); - await harness.mockHealthcheck(page); - - await page.goto('/'); - await page.getByPlaceholder(/enter your password/i).fill('password'); - await page.getByRole('button', { name: /log in/i }).click(); - await expect(page.getByText('Common Info')).toBeVisible(); - }); - - test.afterEach(() => { - harness.reset(); - }); - - test('Static -> DHCP: Rollback should be DISABLED by default', async ({ page }) => { - await harness.setup(page, { ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } }); - await expect(page.getByLabel('Static')).toBeChecked(); - - await page.getByLabel('DHCP').click({ force: true }); - await page.getByRole('button', { name: /save/i }).click(); - - await expect(page.getByText('Confirm Network Configuration Change')).toBeVisible(); - await expect(page.getByRole('checkbox', { name: /Enable automatic rollback/i })).not.toBeChecked(); - }); - - test('DHCP -> Static: Rollback should be ENABLED by default', async ({ page }) => { - await harness.setup(page, { ipv4: { addrs: [{ addr: 'localhost', dhcp: true, prefix_len: 24 }] } }); - await expect(page.getByLabel('DHCP')).toBeChecked(); - - await page.getByLabel('Static').click({ force: true }); - await page.getByRole('textbox', { name: /IP Address/i }).fill('192.168.1.150'); - await page.getByRole('button', { name: /save/i }).click(); - - await expect(page.getByText('Confirm Network Configuration Change')).toBeVisible(); - await expect(page.getByRole('checkbox', { name: /Enable automatic rollback/i })).toBeChecked(); - }); - - test('DHCP -> Static (Same IP): Rollback should be ENABLED', async ({ page }) => { - await harness.setup(page, { ipv4: { addrs: [{ addr: 'localhost', dhcp: true, prefix_len: 24 }] } }); - await expect(page.getByLabel('DHCP')).toBeChecked(); - - await page.getByLabel('Static').click({ force: true }); - // IP is auto-filled with current IP ('localhost'), do NOT change it. - await page.getByRole('button', { name: /save/i }).click(); - - // Verify Modal - await expect(page.getByText('Confirm Network Configuration Change')).toBeVisible(); - - // Verify Checkbox is CHECKED - await expect(page.getByRole('checkbox', { name: /Enable automatic rollback/i })).toBeChecked(); - - // Apply changes - await page.getByRole('button', { name: /apply changes/i }).click(); - - // Verify overlay appears with countdown label - await expect(page.locator('#overlay').getByText('Automatic rollback in:')).toBeVisible({ timeout: 10000 }); - }); - - test('Rollback should show MODAL not SNACKBAR when connection is restored at old IP', async ({ page }) => { - await harness.setup(page, { ipv4: { addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }] } }); - - await page.getByLabel('DHCP').click({ force: true }); - - // Mock /network to return a short rollback timeout for testing - await harness.mockNetworkConfig(page, { rollbackTimeoutSeconds: 2 }); - - await page.getByRole('button', { name: /save/i }).click(); - await expect(page.getByText('Confirm Network Configuration Change')).toBeVisible(); - await page.getByRole('checkbox', { name: /Enable automatic rollback/i }).check(); - - // Override healthcheck mock to return network_rollback_occurred: true - await page.unroute('**/healthcheck'); - await page.route('**/healthcheck', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - version_info: { required: '>=0.39.0', current: '0.40.0', mismatch: false }, - update_validation_status: { status: 'valid' }, - network_rollback_occurred: true, - }), - }); - }); - - await page.getByRole('button', { name: /Apply Changes/i }).click(); - await expect(page.getByText('Applying network settings')).toBeVisible(); - - await expect(page.getByText('Automatic network rollback successful')).not.toBeVisible(); - await expect(page.getByText('The network settings were rolled back to the previous configuration')).toBeVisible({ timeout: 10000 }); - }); -}); - -test.describe('Network Rollback Regression', () => { - let harness: NetworkTestHarness; - - test.beforeEach(async ({ page }) => { - harness = new NetworkTestHarness(); - - // Shim WebSocket to redirect 192.168.1.150 to localhost - await page.addInitScript(() => { - const OriginalWebSocket = window.WebSocket; - // @ts-ignore - window.WebSocket = function(url, protocols) { - if (typeof url === 'string' && url.includes('192.168.1.150')) { - console.log(`[Shim] Redirecting WebSocket ${url} to localhost`); - url = url.replace('192.168.1.150', 'localhost'); - } - return new OriginalWebSocket(url, protocols); - }; - window.WebSocket.prototype = OriginalWebSocket.prototype; - window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING; - window.WebSocket.OPEN = OriginalWebSocket.OPEN; - window.WebSocket.CLOSING = OriginalWebSocket.CLOSING; - window.WebSocket.CLOSED = OriginalWebSocket.CLOSED; - }); - - // Mock the redirect to the new IP to prevent navigation failure - await page.route(/.*192\.168\.1\.150.*/, async (route) => { - const url = new URL(route.request().url()); - - // Handle healthcheck separately to avoid CORS and ensure correct response - if (url.pathname.includes('/healthcheck')) { - await route.fulfill({ - status: 200, - contentType: 'application/json', - headers: { - 'Access-Control-Allow-Origin': 'https://localhost:5173', // or '*' - 'Access-Control-Allow-Credentials': 'true', - }, - body: JSON.stringify({ - version_info: { required: '>=0.39.0', current: '0.40.0', mismatch: false }, - update_validation_status: { status: 'valid' }, - network_rollback_occurred: harness.getRollbackState().occurred, - }), - }); - return; - } - - // For other requests (main page, assets), proxy to localhost - const originalUrl = page.url(); - const originalPort = new URL(originalUrl).port || '5173'; - const originalProtocol = new URL(originalUrl).protocol; - - const newUrl = `${originalProtocol}//localhost:${originalPort}${url.pathname}${url.search}`; - - console.log(`Redirecting ${url.href} to ${newUrl}`); - - try { - const response = await page.request.fetch(newUrl, { - method: route.request().method(), - headers: route.request().headers(), - data: route.request().postDataBuffer(), - ignoreHTTPSErrors: true - }); - - await route.fulfill({ - response: response - }); - } catch (e) { - console.error('Failed to proxy request:', e); - await route.abort(); - } - }); - - await mockConfig(page); - await mockLoginSuccess(page); - await mockRequireSetPassword(page); - await harness.mockNetworkConfig(page, { rollbackTimeoutSeconds: 10 }); // Short timeout - await harness.mockHealthcheck(page); - await harness.mockAckRollback(page); - - await page.goto('/'); - await page.getByPlaceholder(/enter your password/i).fill('password'); - await page.getByRole('button', { name: /log in/i }).click(); - await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); - }); - - test.afterEach(() => { - harness.reset(); - }); - - test('Rollback modal should close on second apply after a rollback', async ({ page }) => { - test.setTimeout(60000); // Increase timeout for rollback scenario - - // Set browser hostname to match the adapter IP so Core detects it as current connection - await page.evaluate(() => { - // @ts-ignore - if (window.setBrowserHostname) { - // @ts-ignore - window.setBrowserHostname('localhost'); - } - }); - - // 1. Setup adapter with static IP (current connection) - await harness.setup(page, { - ipv4: { - addrs: [{ addr: 'localhost', dhcp: false, prefix_len: 24 }], - dns: ['8.8.8.8'], - gateways: ['192.168.1.1'], - }, - }); - - // 2. First Change - will trigger rollback - const ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); - await ipInput.fill('192.168.1.150'); - - await page.getByRole('button', { name: /save/i }).click(); - - const confirmDialog = page.getByText('Confirm Network Configuration Change'); - await expect(confirmDialog).toBeVisible(); - - const rollbackCheckbox = page.getByRole('checkbox', { name: /Enable automatic rollback/i }); - if (!(await rollbackCheckbox.isChecked())) { - await rollbackCheckbox.check(); - } - - await page.getByRole('button', { name: /apply changes/i }).click(); - - // Verify modal closed - await expect(confirmDialog).not.toBeVisible(); - - // Verify rollback overlay appears - await expect(page.locator('#overlay').getByText('Automatic rollback in:')).toBeVisible(); - - // Wait for at least one healthcheck attempt on the new IP - await page.waitForResponse(resp => resp.url().includes('192.168.1.150') && resp.url().includes('healthcheck')); - - // 3. Simulate Rollback Timeout - await harness.simulateRollbackTimeout(); - - // UI should eventually show "Network Settings Rolled Back" - await expect(page.getByText('Network Settings Rolled Back')).toBeVisible({ timeout: 30000 }); - - // 4. Acknowledge Rollback - await page.getByRole('button', { name: /ok/i }).click(); - - // Verify we are back to normal - await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible(); - - // Handle re-login if necessary - if (await page.getByRole('button', { name: /log in/i }).isVisible()) { - await page.getByPlaceholder(/enter your password/i).fill('password'); - await page.getByRole('button', { name: /log in/i }).click(); - await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); - } - - // Force the browser hostname to match the harness IP. - await page.evaluate((ip) => { - // @ts-ignore - if (window.setBrowserHostname) { - // @ts-ignore - window.setBrowserHostname(ip); - } - }, '192.168.1.100'); - - // Ensure we are back on the adapter page - if (!await page.getByRole('textbox', { name: /IP Address/i }).first().isVisible()) { - await harness.navigateToNetwork(page); - await harness.navigateToAdapter(page, 'eth0'); - } - - const currentIpInput = page.getByRole('textbox', { name: /IP Address/i }).first(); - await expect(currentIpInput).toHaveValue('192.168.1.100'); - - // 5. Second Change - try again - await currentIpInput.fill('192.168.1.151'); - await page.getByRole('button', { name: /save/i }).click(); - - await expect(confirmDialog).toBeVisible(); - - // Ensure rollback is checked - if (!(await rollbackCheckbox.isChecked())) { - await rollbackCheckbox.check(); - } - - await page.getByRole('button', { name: /apply changes/i }).click(); - - // 6. Verify modal closed (second time) - await expect(confirmDialog).not.toBeVisible({ timeout: 5000 }); - }); -}); -