Skip to content

Commit 21e0b7f

Browse files
MarkoVcodeclaude
andcommitted
Add comprehensive workbench UI with E2E tests and request logging
Frontend Workbench UI: - Implement full VSCode-style workbench layout with activity bar, sidebar, panels - Add Settings view with instrument configuration and history retention - Implement collapsible accordion for instrument configuration - Add tutorial tooltips for first-time user onboarding - Create bottom panel with logs and Node-RED integration - Add right panel for future expansion Request Logging: - Add RequestLogContext for tracking all API requests - Implement LogsPanel showing real-time request/response data - Add filtering, search, and auto-scroll capabilities - Store logs with timestamps, duration, and status E2E Testing: - Add comprehensive E2E tests for add instrument flow - Add settings view E2E tests - Add history retention settings tests - Add hot reload functionality tests - Enhance test fixtures with modal dismissal Backend Updates: - Add hot reload support via /config/reload endpoint - Update API responses for enhanced version information - Improve config loading error handling - Add serial manager hot reload capability Electron Integration: - Update init-user-data.js for better user data management All changes support the new workbench-style UI architecture and comprehensive testing infrastructure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8b4bb24 commit 21e0b7f

40 files changed

Lines changed: 4996 additions & 144 deletions
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
/**
2+
* E2E tests for Add Instrument functionality
3+
*
4+
* Tests the complete flow of adding a new instrument:
5+
* 1. Click "+" button in Activity Bar
6+
* 2. Form auto-expands in Settings sidebar
7+
* 3. Fill in instrument details
8+
* 4. Save and verify instrument is added
9+
*/
10+
11+
import { test, expect } from './fixtures/base';
12+
13+
test.describe('Add Instrument Flow', () => {
14+
test('should open add instrument form when clicking "+" button', async ({ mockPage }) => {
15+
// In workbench layout, sidebar starts collapsed
16+
// Click the "+" button in Activity Bar
17+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
18+
await expect(addButton).toBeVisible();
19+
await addButton.click();
20+
await mockPage.waitForTimeout(500);
21+
22+
// Sidebar should open with Settings view
23+
const sidebar = mockPage.locator('[data-testid="sidebar"]');
24+
await expect(sidebar).toBeVisible();
25+
await expect(sidebar.locator('.sidebar__title')).toHaveText('Settings');
26+
27+
// Settings view should show Instruments tab as active
28+
const settingsView = mockPage.locator('[data-testid="settings-view"]');
29+
await expect(settingsView).toBeVisible();
30+
31+
const instrumentsTab = settingsView.locator('[data-testid="settings-tab-instruments"]');
32+
await expect(instrumentsTab).toHaveClass(/settings-view__tab--active/);
33+
});
34+
35+
test('should auto-expand new instrument form when "+" clicked', async ({ mockPage }) => {
36+
// In workbench layout, sidebar starts collapsed
37+
// Click "+" button
38+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
39+
await addButton.click();
40+
await mockPage.waitForTimeout(1000); // Give time for auto-add to trigger
41+
42+
// Check that a new instrument accordion item appeared
43+
const accordionItems = mockPage.locator('.accordion__item');
44+
const count = await accordionItems.count();
45+
46+
// Should have at least 3 items (2 existing + 1 new)
47+
expect(count).toBeGreaterThanOrEqual(3);
48+
49+
// Find the new instrument (look for "New Instrument" text)
50+
const newInstrumentHeader = mockPage.locator('.accordion__header:has-text("New Instrument")');
51+
await expect(newInstrumentHeader).toBeVisible();
52+
53+
// The new instrument accordion should be expanded
54+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
55+
await expect(newInstrumentItem).toHaveClass(/accordion__item--expanded/);
56+
});
57+
58+
test('should allow filling in instrument details without closing', async ({ mockPage }) => {
59+
await mockPage.waitForTimeout(300);
60+
61+
// Click "+" button
62+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
63+
await addButton.click();
64+
await mockPage.waitForTimeout(1000);
65+
66+
// Find the new instrument form
67+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
68+
await expect(newInstrumentItem).toBeVisible();
69+
70+
// Type in the ID field
71+
const idInput = newInstrumentItem.locator('input[type="text"]').first();
72+
await idInput.click();
73+
await idInput.fill('test-device-1');
74+
75+
// Wait a bit to see if form closes
76+
await mockPage.waitForTimeout(500);
77+
78+
// Form should still be expanded
79+
await expect(newInstrumentItem).toHaveClass(/accordion__item--expanded/);
80+
81+
// Type in the name field
82+
const nameInput = newInstrumentItem.locator('input[type="text"]').nth(1);
83+
await nameInput.click();
84+
await nameInput.fill('Test Device');
85+
await mockPage.waitForTimeout(500);
86+
87+
// Form should still be expanded
88+
await expect(newInstrumentItem).toHaveClass(/accordion__item--expanded/);
89+
90+
// Verify values were typed
91+
await expect(idInput).toHaveValue('test-device-1');
92+
await expect(nameInput).toHaveValue('Test Device');
93+
});
94+
95+
test('should persist form data while selecting driver', async ({ mockPage }) => {
96+
await mockPage.waitForTimeout(300);
97+
98+
// Click "+" button
99+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
100+
await addButton.click();
101+
await mockPage.waitForTimeout(1000);
102+
103+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
104+
await expect(newInstrumentItem).toBeVisible();
105+
106+
// Fill ID
107+
const idInput = newInstrumentItem.locator('input[type="text"]').first();
108+
await idInput.fill('test-psu-1');
109+
await mockPage.waitForTimeout(300);
110+
111+
// Select driver from dropdown
112+
const driverSelect = newInstrumentItem.locator('select').first();
113+
await driverSelect.selectOption({ index: 1 }); // Select first available driver
114+
await mockPage.waitForTimeout(500);
115+
116+
// Form should still be expanded
117+
await expect(newInstrumentItem).toHaveClass(/accordion__item--expanded/);
118+
119+
// ID should still be there
120+
await expect(idInput).toHaveValue('test-psu-1');
121+
});
122+
123+
test('should show validation errors for invalid input', async ({ mockPage }) => {
124+
await mockPage.waitForTimeout(300);
125+
126+
// Click "+" button
127+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
128+
await addButton.click();
129+
await mockPage.waitForTimeout(1000);
130+
131+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
132+
133+
// Try to save without filling required fields
134+
const addInstrumentButton = newInstrumentItem.locator('button:has-text("Add Instrument")');
135+
await expect(addInstrumentButton).toBeVisible();
136+
await addInstrumentButton.click();
137+
await mockPage.waitForTimeout(500);
138+
139+
// Should show error message
140+
const errorMessage = newInstrumentItem.locator('.instrument-config__error');
141+
await expect(errorMessage).toBeVisible();
142+
});
143+
144+
test('should successfully add instrument with valid data', async ({ mockPage }) => {
145+
// Mock the POST /devices endpoint
146+
await mockPage.route('**/devices', async (route) => {
147+
if (route.request().method() === 'POST') {
148+
await route.fulfill({
149+
status: 200,
150+
contentType: 'application/json',
151+
body: JSON.stringify({ status: 'ok', message: 'Device added successfully' })
152+
});
153+
} else {
154+
await route.continue();
155+
}
156+
});
157+
158+
// Mock updated instruments list
159+
await mockPage.route('**/instruments', async (route) => {
160+
await route.fulfill({
161+
status: 200,
162+
contentType: 'application/json',
163+
body: JSON.stringify([
164+
{
165+
id: 'test-psu-1',
166+
name: 'Test PSU',
167+
IDN: 'TEST,PSU,SN123,V1.0',
168+
classes: [{ class: 'PSU', channels: ['1'], ui_component: 'GenericPSU' }]
169+
}
170+
]),
171+
headers: {
172+
'ETag': '"new-etag"'
173+
}
174+
});
175+
});
176+
177+
await mockPage.waitForTimeout(300);
178+
179+
// Click "+" button
180+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
181+
await addButton.click();
182+
await mockPage.waitForTimeout(1000);
183+
184+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
185+
186+
// Fill in all required fields
187+
const idInput = newInstrumentItem.locator('input[type="text"]').first();
188+
await idInput.fill('test-psu-1');
189+
190+
const nameInput = newInstrumentItem.locator('input[type="text"]').nth(1);
191+
await nameInput.fill('Test PSU');
192+
193+
// Select driver (assuming first option after empty is a valid driver)
194+
const driverSelect = newInstrumentItem.locator('select').first();
195+
await driverSelect.selectOption({ index: 1 });
196+
await mockPage.waitForTimeout(300);
197+
198+
// Select model if dropdown appears
199+
const modelSelect = newInstrumentItem.locator('select').nth(1);
200+
if (await modelSelect.isVisible()) {
201+
await modelSelect.selectOption({ index: 1 });
202+
}
203+
204+
// Fill port
205+
const portInput = newInstrumentItem.locator('input[type="text"]').nth(2);
206+
await portInput.fill('/dev/ttyUSB0');
207+
208+
// Click Add Instrument button
209+
const addInstrumentButton = newInstrumentItem.locator('button:has-text("Add Instrument")');
210+
await addInstrumentButton.click();
211+
212+
// Wait for success
213+
await mockPage.waitForTimeout(1000);
214+
215+
// Should show success message
216+
const successMessage = newInstrumentItem.locator('.instrument-config__success');
217+
await expect(successMessage).toBeVisible();
218+
});
219+
220+
test('should close form when clicking accordion header', async ({ mockPage }) => {
221+
await mockPage.waitForTimeout(300);
222+
223+
// Click "+" button
224+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
225+
await addButton.click();
226+
await mockPage.waitForTimeout(1000);
227+
228+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
229+
await expect(newInstrumentItem).toHaveClass(/accordion__item--expanded/);
230+
231+
// Click the accordion header to collapse
232+
const header = newInstrumentItem.locator('.accordion__header');
233+
await header.click();
234+
await mockPage.waitForTimeout(300);
235+
236+
// Should be collapsed now
237+
await expect(newInstrumentItem).not.toHaveClass(/accordion__item--expanded/);
238+
});
239+
240+
test('should allow reopening collapsed form', async ({ mockPage }) => {
241+
await mockPage.waitForTimeout(300);
242+
243+
// Click "+" button
244+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
245+
await addButton.click();
246+
await mockPage.waitForTimeout(1000);
247+
248+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
249+
250+
// Collapse it
251+
await newInstrumentItem.locator('.accordion__header').click();
252+
await mockPage.waitForTimeout(300);
253+
await expect(newInstrumentItem).not.toHaveClass(/accordion__item--expanded/);
254+
255+
// Reopen it
256+
await newInstrumentItem.locator('.accordion__header').click();
257+
await mockPage.waitForTimeout(300);
258+
await expect(newInstrumentItem).toHaveClass(/accordion__item--expanded/);
259+
});
260+
261+
test('should not create multiple new instruments on repeated "+" clicks', async ({ mockPage }) => {
262+
await mockPage.waitForTimeout(300);
263+
264+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
265+
266+
// Click "+" multiple times
267+
await addButton.click();
268+
await mockPage.waitForTimeout(500);
269+
await addButton.click();
270+
await mockPage.waitForTimeout(500);
271+
await addButton.click();
272+
await mockPage.waitForTimeout(500);
273+
274+
// Should only have ONE new instrument
275+
const newInstrumentHeaders = mockPage.locator('.accordion__header:has-text("New Instrument")');
276+
const count = await newInstrumentHeaders.count();
277+
expect(count).toBe(1);
278+
});
279+
280+
test('should handle canceling new instrument creation', async ({ mockPage }) => {
281+
await mockPage.waitForTimeout(300);
282+
283+
// Click "+" button
284+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
285+
await addButton.click();
286+
await mockPage.waitForTimeout(1000);
287+
288+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
289+
await expect(newInstrumentItem).toBeVisible();
290+
291+
// Fill in some data
292+
const idInput = newInstrumentItem.locator('input[type="text"]').first();
293+
await idInput.fill('temp-device');
294+
295+
// Find and click cancel/remove button (✕ button)
296+
const removeButton = newInstrumentItem.locator('button[title*="Remove"], button:has-text("✕")').first();
297+
if (await removeButton.isVisible()) {
298+
await removeButton.click();
299+
await mockPage.waitForTimeout(500);
300+
301+
// New instrument should be gone
302+
await expect(newInstrumentItem).not.toBeVisible();
303+
}
304+
});
305+
});
306+
307+
test.describe('Add Instrument Form State Management', () => {
308+
test('should not reset form when clicking outside accordion', async ({ mockPage }) => {
309+
await mockPage.waitForTimeout(300);
310+
311+
// Click "+" button
312+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
313+
await addButton.click();
314+
await mockPage.waitForTimeout(1000);
315+
316+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
317+
318+
// Fill in ID
319+
const idInput = newInstrumentItem.locator('input[type="text"]').first();
320+
await idInput.fill('persistent-test');
321+
await mockPage.waitForTimeout(300);
322+
323+
// Click somewhere else in the sidebar (but not on accordion header)
324+
const settingsView = mockPage.locator('[data-testid="settings-view"]');
325+
await settingsView.click({ position: { x: 10, y: 10 } });
326+
await mockPage.waitForTimeout(300);
327+
328+
// Form should still be expanded with data intact
329+
await expect(newInstrumentItem).toHaveClass(/accordion__item--expanded/);
330+
await expect(idInput).toHaveValue('persistent-test');
331+
});
332+
333+
test('should preserve form data when switching tabs and back', async ({ mockPage }) => {
334+
// Click "+" button
335+
const addButton = mockPage.locator('[data-testid="activity-bar-item-add-instrument"]');
336+
await addButton.click();
337+
await mockPage.waitForTimeout(1000);
338+
339+
const newInstrumentItem = mockPage.locator('.accordion__item:has-text("New Instrument")');
340+
341+
// Fill in some data
342+
const idInput = newInstrumentItem.locator('input[type="text"]').first();
343+
await idInput.fill('tab-test-device');
344+
await mockPage.waitForTimeout(300);
345+
346+
// Switch to Miscellaneous tab
347+
const miscTab = mockPage.locator('[data-testid="settings-tab-miscellaneous"]');
348+
await miscTab.click();
349+
await mockPage.waitForTimeout(500);
350+
351+
// Switch back to Instruments tab
352+
const instrumentsTab = mockPage.locator('[data-testid="settings-tab-instruments"]');
353+
await instrumentsTab.click();
354+
await mockPage.waitForTimeout(500);
355+
356+
// Data should still be there
357+
await expect(idInput).toHaveValue('tab-test-device');
358+
});
359+
});

0 commit comments

Comments
 (0)