Skip to content

Commit 355ec7f

Browse files
authored
SY-3549: Automate Workspaces QA (#1979)
* Automate Workspaces QA * Extend workspaces client to handle file imports * Add workspace/pages for import * Reorganize tests
1 parent ddc5c76 commit 355ec7f

32 files changed

Lines changed: 2028 additions & 273 deletions

.github/PULL_REQUEST_TEMPLATE/rc.md

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -426,25 +426,14 @@ I can successfully:
426426

427427
I can successfully:
428428

429-
- [ ] View the correct version in the bottom navbar.
430429
- [ ] Verify that the auto-update functionality works correctly.
431430

432431
### Workspaces
433432

434433
I can successfully:
435434

436435
- [ ] Import a workspace by drag and dropping from a directory.
437-
438-
- **Resources Toolbar**
439-
- **Context Menu**
440-
- [ ] Export a workspace.
441-
- [ ] Import a line plot.
442-
- [ ] Import a schematic.
443-
- [ ] Import a log.
444-
- [ ] Import a table.
445436
- [ ] Open a workspace from a link.
446-
- **Search and Command Palette**
447-
- [ ] Import a workspace.
448437
- [ ] Create a workspace in a previous version of Synnax, add visualizations, and open it in the release candidate.
449438

450439
## Driver

integration/console/layout.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,20 @@ def close_left_toolbar(self) -> None:
299299

300300
nav_drawer.wait_for(state="hidden", timeout=5000)
301301

302+
def get_version(self) -> str:
303+
"""Get the version string displayed in the navbar badge.
304+
305+
Returns:
306+
The version string (e.g., "v0.51.0").
307+
"""
308+
navbar_end = self.page.locator(
309+
".pluto-navbar__content.pluto--end[data-tauri-drag-region]"
310+
)
311+
navbar_end.wait_for(state="visible", timeout=15000)
312+
version_badge = navbar_end.locator("button").first
313+
version_badge.wait_for(state="visible", timeout=5000)
314+
return version_badge.inner_text().strip()
315+
302316
def fill_input_field(self, input_label: str, value: str) -> None:
303317
"""Fill an input field by label."""
304318
input_field = (

integration/console/plot.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,19 +274,22 @@ def get_line_thickness(self) -> int:
274274
stroke_input = line_item.locator("input").nth(1)
275275
return int(stroke_input.input_value())
276276

277-
def get_line_label(self) -> str:
278-
"""Get the label for the first line from the Lines tab.
277+
def get_line_labels(self) -> list[str]:
278+
"""Get labels for all lines from the Lines tab.
279279
280280
Returns:
281-
The current line label
281+
List of line label strings.
282282
"""
283283
self.notifications.close_all()
284+
self.layout.show_visualization_toolbar()
284285
self.page.locator("#lines").click(timeout=5000)
285286

286287
lines_container = self.page.locator(".console-line-plot__toolbar-lines")
287-
line_item = lines_container.locator(".pluto-list__item").first
288-
label_input = line_item.locator("input").first
289-
return label_input.input_value()
288+
line_items = lines_container.locator(".pluto-list__item")
289+
count = line_items.count()
290+
return [
291+
line_items.nth(i).locator("input").first.input_value() for i in range(count)
292+
]
290293

291294
def drag_channel_to_canvas(
292295
self, channel_name: str, channels: ChannelClient

integration/console/schematic/schematic.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,10 @@ def get_properties(self) -> SchematicProperties:
465465
"show_control_legend": show_control_legend,
466466
}
467467

468+
def get_symbol_count(self) -> int:
469+
"""Get the number of symbol nodes on the schematic pane."""
470+
return self.page.locator(".react-flow__node").count()
471+
468472
@property
469473
def control_legend_visible(self) -> bool:
470474
"""Check if the control state legend is visible."""

integration/console/schematic/symbol_editor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def upload_svg(self, svg_path: str) -> None:
7777
for word in name_without_ext.replace("_", " ").replace("-", " ").split()
7878
)
7979

80+
# SY-3670
8081
self.page.evaluate(
8182
"""([svgContent, properName]) => {
8283
// Access React fiber to call FileDrop's onContentsChange prop directly

integration/console/table.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from console.layout import LayoutClient
1414
from console.page import ConsolePage
1515

16+
DATA_ROW_SELECTOR = ".pluto-table__row:not(.pluto-table__col-resizer)"
17+
1618

1719
class Table(ConsolePage):
1820
"""Table page management interface"""
@@ -57,18 +59,42 @@ def get_cell_channel(self, row: int = 0, col: int = 0) -> str:
5759
Returns:
5860
The channel name or empty string if not set
5961
"""
60-
self.layout.get_tab(self.page_name).click()
61-
self._click_cell(row, col)
62-
self.layout.show_visualization_toolbar()
62+
self._select_cell(row, col)
6363
self.page.get_by_text("Telemetry").click()
6464
channel_btn = (
6565
self.page.locator("text=Input Channel")
6666
.locator("..")
6767
.locator("button")
6868
.first
6969
)
70-
channel_text = channel_btn.inner_text().strip()
71-
return channel_text
70+
return channel_btn.inner_text().strip()
71+
72+
def get_cell_text(self, row: int = 0, col: int = 0) -> str:
73+
"""Get the text content of a text cell.
74+
75+
Args:
76+
row: Row index (0-based)
77+
col: Column index (0-based)
78+
79+
Returns:
80+
The text value of the cell
81+
"""
82+
self._select_cell(row, col)
83+
text_input = self.page.locator("text=Text").locator("..").locator("input").first
84+
return text_input.input_value().strip()
85+
86+
def has_text(self, text: str, row: int = 0, col: int = 0) -> bool:
87+
"""Check if a text cell contains the given text.
88+
89+
Args:
90+
text: Text to check for
91+
row: Row index (0-based)
92+
col: Column index (0-based)
93+
94+
Returns:
95+
True if the cell text matches
96+
"""
97+
return self.get_cell_text(row, col) == text
7298

7399
def has_channel(self, channel_name: str, row: int = 0, col: int = 0) -> bool:
74100
"""Check if a channel is shown in a cell.
@@ -83,13 +109,27 @@ def has_channel(self, channel_name: str, row: int = 0, col: int = 0) -> bool:
83109
"""
84110
return channel_name in self.get_cell_channel(row, col)
85111

112+
def _select_cell(self, row: int, col: int) -> None:
113+
"""Focus the tab, click a cell, and open the visualization toolbar."""
114+
self.layout.get_tab(self.page_name).click()
115+
self._click_cell(row, col)
116+
self.layout.show_visualization_toolbar()
117+
86118
def _click_cell(self, row: int, col: int) -> None:
87119
"""Click on a specific cell in the table."""
88120
cells = self.page.locator(".pluto-table__cell")
89-
cell_index = row * self._get_column_count() + col
121+
cell_index = row * self.get_column_count() + col
90122
cells.nth(cell_index).click()
91123

92-
def _get_column_count(self) -> int:
93-
"""Get the number of columns in the table."""
94-
first_row = self.page.locator(".pluto-table__row").first
95-
return first_row.locator(".pluto-table__cell").count()
124+
def get_row_count(self) -> int:
125+
"""Get the number of data rows in the table (excludes the column resizer row)."""
126+
self.page.locator(DATA_ROW_SELECTOR).first.wait_for(
127+
state="visible", timeout=5000
128+
)
129+
return self.page.locator(DATA_ROW_SELECTOR).count()
130+
131+
def get_column_count(self) -> int:
132+
"""Get the number of data columns in the table (excludes the row resizer cell)."""
133+
data_row = self.page.locator(DATA_ROW_SELECTOR).first
134+
data_row.wait_for(state="visible", timeout=5000)
135+
return data_row.locator(".pluto-table__cell").count()

integration/console/workspace.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,31 @@
3535

3636
T = TypeVar("T", bound="ConsolePage")
3737

38+
# SY-3670 — Shared JS snippet that walks the React fiber tree from #root
39+
# to find the Redux store. Used by import_page and export_workspace as a
40+
# browser-mode workaround for features that normally rely on Tauri APIs.
41+
_FIND_REDUX_STORE_JS = """
42+
const rootEl = document.getElementById('root');
43+
if (!rootEl) throw new Error('Root element not found');
44+
const containerKey = Object.keys(rootEl).find(
45+
k => k.startsWith('__reactContainer$')
46+
);
47+
if (!containerKey) throw new Error('React container not found');
48+
let store = null;
49+
const stack = [rootEl[containerKey]];
50+
while (stack.length > 0) {
51+
const fiber = stack.pop();
52+
if (!fiber) continue;
53+
if (fiber.memoizedProps?.store?.dispatch) {
54+
store = fiber.memoizedProps.store;
55+
break;
56+
}
57+
if (fiber.child) stack.push(fiber.child);
58+
if (fiber.sibling) stack.push(fiber.sibling);
59+
}
60+
if (!store) throw new Error('Redux store not found');
61+
"""
62+
3863

3964
class WorkspaceClient:
4065
"""Workspace management for Console UI automation."""
@@ -449,6 +474,180 @@ def export_page(self, name: str) -> dict[str, Any]:
449474
result: dict[str, Any] = json.load(f)
450475
return result
451476

477+
def _evaluate_with_redux(self, js_body: str, args: Any = None) -> Any:
478+
"""Execute JS with the Redux store available as ``store``.
479+
480+
# SY-3670 — Uses React fiber walking to locate the Redux store
481+
# from the React root. This is a browser-mode workaround for
482+
# features that normally rely on Tauri APIs.
483+
484+
Args:
485+
js_body: JS code to execute. ``store`` and ``args`` are in scope.
486+
args: Optional arguments forwarded to the JS function.
487+
"""
488+
js = "(args) => {" + _FIND_REDUX_STORE_JS + js_body + "}"
489+
return self.layout.page.evaluate(js, args)
490+
491+
def import_page(self, json_path: str, name: str) -> None:
492+
"""Import a page from a JSON file via direct JS injection.
493+
494+
Since the console uses Tauri's native file dialog for imports
495+
(unavailable in browser mode), this method bypasses the dialog
496+
by reading the JSON file and dispatching Redux actions directly.
497+
498+
# SY-3670 will address this issue.
499+
500+
Args:
501+
json_path: Path to the JSON file to import.
502+
name: Display name for the imported page tab.
503+
"""
504+
with open(json_path, "r") as f:
505+
data: dict[str, Any] = json.load(f)
506+
507+
resource_type = data.get("type")
508+
if resource_type is None:
509+
raise ValueError(f"JSON file missing 'type' field: {json_path}")
510+
511+
type_config: dict[str, dict[str, str]] = {
512+
"lineplot": {"slice": "line", "icon": "Visualize"},
513+
"schematic": {"slice": "schematic", "icon": "Schematic"},
514+
"log": {"slice": "log", "icon": "Log"},
515+
"table": {"slice": "table", "icon": "Table"},
516+
}
517+
518+
config = type_config.get(resource_type)
519+
if config is None:
520+
raise ValueError(f"Unsupported resource type: {resource_type}")
521+
522+
self._evaluate_with_redux(
523+
"""
524+
const [data, name, sliceName, icon] = args;
525+
const key = crypto.randomUUID();
526+
store.dispatch({
527+
type: sliceName + '/create',
528+
payload: { ...data, key }
529+
});
530+
store.dispatch({
531+
type: 'layout/place',
532+
payload: {
533+
key, name,
534+
location: 'mosaic',
535+
type: data.type,
536+
icon,
537+
windowKey: 'main',
538+
}
539+
});
540+
""",
541+
[data, name, config["slice"], config["icon"]],
542+
)
543+
544+
self.layout.get_tab(name).wait_for(state="visible", timeout=10000)
545+
546+
def import_workspace(self, name: str, data: dict[str, Any]) -> None:
547+
"""Import a workspace via command palette with JS injection fallback.
548+
549+
Triggers "Import a workspace" from the command palette. Since the
550+
console uses Tauri's native directory dialog (unavailable in browser
551+
mode), the actual import falls back to dispatching Redux actions
552+
via JS injection.
553+
554+
# SY-3670 will address the browser-mode limitation.
555+
556+
Args:
557+
name: Name for the imported workspace.
558+
data: Export data dict with 'layout' and 'components' keys
559+
(as returned by export_workspace).
560+
"""
561+
self.layout.command_palette("Import a workspace")
562+
563+
sliceMap: dict[str, str] = {
564+
"lineplot": "line",
565+
"schematic": "schematic",
566+
"log": "log",
567+
"table": "table",
568+
}
569+
570+
self._evaluate_with_redux(
571+
"""
572+
const [name, layoutData, components, sliceMap] = args;
573+
const wsKey = crypto.randomUUID();
574+
store.dispatch({
575+
type: 'workspace/setActive',
576+
payload: { key: wsKey, name, layout: layoutData },
577+
});
578+
store.dispatch({
579+
type: 'layout/setWorkspace',
580+
payload: { slice: layoutData, keepNav: false },
581+
});
582+
for (const [key, component] of Object.entries(components)) {
583+
const sliceName = sliceMap[component.type];
584+
if (!sliceName) continue;
585+
store.dispatch({
586+
type: sliceName + '/create',
587+
payload: { ...component, key },
588+
});
589+
}
590+
""",
591+
[name, data["layout"], data["components"], sliceMap],
592+
)
593+
594+
self.layout.page.get_by_role("button").filter(has_text=name).wait_for(
595+
state="visible", timeout=10000
596+
)
597+
598+
def export_workspace(self, name: str) -> dict[str, Any]:
599+
"""Export a workspace via context menu with JS injection fallback.
600+
601+
Opens the context menu on the workspace and clicks Export. Since the
602+
console uses Tauri's native directory dialog and file system APIs
603+
(unavailable in browser mode), the actual data extraction falls back
604+
to reading Redux state via JS injection.
605+
606+
# SY-3670 will address the browser-mode limitation.
607+
608+
Args:
609+
name: Name of the workspace to export.
610+
611+
Returns:
612+
Dict with 'layout' (the layout state) and 'components'
613+
(dict of component key -> exported state with type field).
614+
"""
615+
self.layout.show_resource_toolbar("workspace")
616+
workspace_item = self.get_item(name)
617+
workspace_item.wait_for(state="visible", timeout=5000)
618+
self.ctx_menu.action(workspace_item, "Export")
619+
self.notifications.close_all()
620+
621+
result: dict[str, Any] = self._evaluate_with_redux("""
622+
const state = store.getState();
623+
const layoutState = state.layout;
624+
const sliceMap = {
625+
lineplot: { slice: 'line', collection: 'plots' },
626+
schematic: { slice: 'schematic', collection: 'schematics' },
627+
log: { slice: 'log', collection: 'logs' },
628+
table: { slice: 'table', collection: 'tables' },
629+
};
630+
const components = {};
631+
const layouts = {};
632+
for (const [key, layout] of Object.entries(layoutState.layouts)) {
633+
if (layout.excludeFromWorkspace || layout.location === 'modal')
634+
continue;
635+
layouts[key] = layout;
636+
const mapping = sliceMap[layout.type];
637+
if (!mapping) continue;
638+
const cs = state[mapping.slice]?.[mapping.collection]?.[key];
639+
if (cs) components[key] = { ...cs, type: layout.type };
640+
}
641+
return { layout: { ...layoutState, layouts }, components };
642+
""")
643+
644+
save_path = get_results_path(f"{name}_export.json")
645+
with open(save_path, "w") as f:
646+
json.dump(result, f, indent=2)
647+
648+
self.layout.close_left_toolbar()
649+
return result
650+
452651
def snapshot_page_to_active_range(self, name: str, range_name: str) -> None:
453652
"""Snapshot a page to the active range via context menu.
454653

0 commit comments

Comments
 (0)