@fdm-monster/client-next 2.2.2 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.yarn/install-state.gz +0 -0
  2. package/README.md +19 -0
  3. package/RELEASE_NOTES.MD +21 -0
  4. package/dist/assets/index-CvbkNANW.js +105 -0
  5. package/dist/assets/index-CvbkNANW.js.map +1 -0
  6. package/dist/assets/index-DfA7W6iO.css +1 -0
  7. package/dist/index.html +3 -3
  8. package/package.json +21 -2
  9. package/screenshots/COVERAGE.md +383 -0
  10. package/screenshots/README.md +431 -0
  11. package/screenshots/fixtures/api-mock.ts +699 -0
  12. package/screenshots/fixtures/data/auth.fixtures.ts +79 -0
  13. package/screenshots/fixtures/data/cameras.fixtures.ts +48 -0
  14. package/screenshots/fixtures/data/files.fixtures.ts +56 -0
  15. package/screenshots/fixtures/data/floors.fixtures.ts +39 -0
  16. package/screenshots/fixtures/data/jobs.fixtures.ts +172 -0
  17. package/screenshots/fixtures/data/printers.fixtures.ts +132 -0
  18. package/screenshots/fixtures/data/settings.fixtures.ts +62 -0
  19. package/screenshots/fixtures/socketio-mock.ts +76 -0
  20. package/screenshots/fixtures/test-fixtures.ts +112 -0
  21. package/screenshots/helpers/dialog.helper.ts +196 -0
  22. package/screenshots/helpers/form.helper.ts +207 -0
  23. package/screenshots/helpers/navigation.helper.ts +191 -0
  24. package/screenshots/playwright.screenshots.config.ts +70 -0
  25. package/screenshots/suites/00-example.screenshots.spec.ts +29 -0
  26. package/screenshots/suites/01-auth.screenshots.spec.ts +130 -0
  27. package/screenshots/suites/02-dashboard.screenshots.spec.ts +106 -0
  28. package/screenshots/suites/03-printer-grid.screenshots.spec.ts +160 -0
  29. package/screenshots/suites/04-printer-list.screenshots.spec.ts +184 -0
  30. package/screenshots/suites/05-camera-grid.screenshots.spec.ts +127 -0
  31. package/screenshots/suites/06-print-jobs.screenshots.spec.ts +139 -0
  32. package/screenshots/suites/07-queue.screenshots.spec.ts +86 -0
  33. package/screenshots/suites/08-files.screenshots.spec.ts +142 -0
  34. package/screenshots/suites/09-settings.screenshots.spec.ts +130 -0
  35. package/screenshots/suites/10-panels-dialogs.screenshots.spec.ts +245 -0
  36. package/screenshots/utils.ts +216 -0
  37. package/vitest.config.ts +8 -0
  38. package/dist/assets/index-BlOaSQti.js +0 -105
  39. package/dist/assets/index-BlOaSQti.js.map +0 -1
  40. package/dist/assets/index-TeWdSn_6.css +0 -1
@@ -0,0 +1,112 @@
1
+ import { test as base, Page } from '@playwright/test';
2
+ import { ApiMock } from './api-mock';
3
+ import { mockSocketIO } from './socketio-mock';
4
+ import { mockPrinters } from './data/printers.fixtures';
5
+ import { mockFloors } from './data/floors.fixtures';
6
+ import { mockCameras } from './data/cameras.fixtures';
7
+ import { mockFiles } from './data/files.fixtures';
8
+ import { mockJobs } from './data/jobs.fixtures';
9
+
10
+ /**
11
+ * Custom fixtures for screenshot tests
12
+ * Extends Playwright's base test with additional fixtures for API mocking and authenticated pages
13
+ */
14
+ type ScreenshotFixtures = {
15
+ apiMock: ApiMock;
16
+ authenticatedPage: Page;
17
+ mockPrinters: typeof mockPrinters;
18
+ mockFloors: typeof mockFloors;
19
+ mockCameras: typeof mockCameras;
20
+ mockFiles: typeof mockFiles;
21
+ mockJobs: typeof mockJobs;
22
+ };
23
+
24
+ /**
25
+ * Extended test object with custom fixtures
26
+ * Usage: import { test, expect } from '../fixtures/test-fixtures'
27
+ */
28
+ export const test = base.extend<ScreenshotFixtures>({
29
+ /**
30
+ * Page fixture override - automatically mocks SocketIO for all pages
31
+ * This prevents "server disconnected" messages and injects initial data
32
+ */
33
+ page: async ({ page }, use) => {
34
+ // Mock SocketIO with initial data before page loads
35
+ await mockSocketIO(page, {
36
+ floors: mockFloors,
37
+ printers: mockPrinters,
38
+ socketStates: {},
39
+ printerEvents: {},
40
+ trackedUploads: { current: [] },
41
+ });
42
+
43
+ // Let all assets (PNG, JPG, SVG) load normally from Vite dev server
44
+ await use(page);
45
+ },
46
+
47
+ /**
48
+ * ApiMock fixture - provides API mocking instance
49
+ * Automatically cleans up routes after each test
50
+ */
51
+ apiMock: async ({ page }, use) => {
52
+ const mock = new ApiMock(page);
53
+ await use(mock);
54
+ await mock.unmockAll();
55
+ },
56
+
57
+ /**
58
+ * Authenticated page fixture - provides a page with mocked authentication
59
+ * Use this when you need to test pages that require login
60
+ * Note: Socket.IO data is injected via page fixture above
61
+ */
62
+ authenticatedPage: async ({ page, apiMock }, use) => {
63
+ // Mock authentication endpoints (no login required)
64
+ await apiMock.mockAuthEndpoints({ loginRequired: false });
65
+
66
+ // Navigate to the app
67
+ await page.goto('/');
68
+
69
+ // Set auth tokens in localStorage
70
+ await page.evaluate(() => {
71
+ localStorage.setItem(
72
+ 'auth-token',
73
+ 'mock-jwt-token-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
74
+ );
75
+ localStorage.setItem(
76
+ 'refresh-token',
77
+ 'mock-refresh-token-abcd1234efgh5678ijkl9012'
78
+ );
79
+ });
80
+
81
+ await use(page);
82
+ },
83
+
84
+ /**
85
+ * Mock data fixtures - provides test data
86
+ * These can be imported and used in tests or modified as needed
87
+ */
88
+ mockPrinters: async ({}, use) => {
89
+ await use(mockPrinters);
90
+ },
91
+
92
+ mockFloors: async ({}, use) => {
93
+ await use(mockFloors);
94
+ },
95
+
96
+ mockCameras: async ({}, use) => {
97
+ await use(mockCameras);
98
+ },
99
+
100
+ mockFiles: async ({}, use) => {
101
+ await use(mockFiles);
102
+ },
103
+
104
+ mockJobs: async ({}, use) => {
105
+ await use(mockJobs);
106
+ },
107
+ });
108
+
109
+ /**
110
+ * Re-export expect from Playwright for convenience
111
+ */
112
+ export { expect } from '@playwright/test';
@@ -0,0 +1,196 @@
1
+ import { Page, Locator } from '@playwright/test';
2
+
3
+ /**
4
+ * Helper utilities for interacting with Vuetify dialogs and modals
5
+ */
6
+
7
+ export class DialogHelper {
8
+ constructor(private page: Page) {}
9
+
10
+ /**
11
+ * Wait for a dialog to appear
12
+ * @param selector Optional specific dialog selector, defaults to active Vuetify dialog
13
+ * @param timeout Maximum time to wait in milliseconds
14
+ */
15
+ async waitForDialog(selector?: string, timeout = 5000): Promise<Locator> {
16
+ if (selector) {
17
+ await this.page.waitForSelector(selector, {
18
+ state: 'visible',
19
+ timeout,
20
+ });
21
+ return this.page.locator(selector).first();
22
+ }
23
+
24
+ // For Vuetify 3, try multiple selectors
25
+ const selectors = [
26
+ '.v-overlay--active .v-card',
27
+ '.v-dialog .v-card',
28
+ '[role="dialog"]',
29
+ '.v-dialog--active',
30
+ ];
31
+
32
+ for (const sel of selectors) {
33
+ const visible = await this.page.locator(sel).isVisible().catch(() => false);
34
+ if (visible) {
35
+ return this.page.locator(sel).first();
36
+ }
37
+ }
38
+
39
+ // Wait for any of the selectors
40
+ await this.page.waitForSelector('.v-overlay--active .v-card, .v-dialog .v-card, [role="dialog"]', {
41
+ state: 'visible',
42
+ timeout,
43
+ });
44
+
45
+ return this.page.locator('.v-overlay--active .v-card, .v-dialog .v-card').first();
46
+ }
47
+
48
+ /**
49
+ * Wait for dialog to disappear
50
+ * @param selector Optional specific dialog selector
51
+ * @param timeout Maximum time to wait in milliseconds
52
+ */
53
+ async waitForDialogToClose(selector?: string, timeout = 5000): Promise<void> {
54
+ const dialogSelector = selector || '.v-dialog--active';
55
+ await this.page.waitForSelector(dialogSelector, {
56
+ state: 'hidden',
57
+ timeout,
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Click the close button in the dialog (usually the X button)
63
+ */
64
+ async closeDialog(): Promise<void> {
65
+ // Try multiple common close button selectors
66
+ const closeSelectors = [
67
+ '[data-testid="dialog-close"]',
68
+ '[data-testid="close-dialog"]',
69
+ '.v-dialog button[aria-label="Close"]',
70
+ '.v-dialog .v-btn--icon',
71
+ ];
72
+
73
+ for (const selector of closeSelectors) {
74
+ const button = this.page.locator(selector).first();
75
+ if (await button.isVisible({ timeout: 1000 }).catch(() => false)) {
76
+ await button.click();
77
+ return;
78
+ }
79
+ }
80
+
81
+ // Fallback: press Escape key
82
+ await this.page.keyboard.press('Escape');
83
+ }
84
+
85
+ /**
86
+ * Click the submit/confirm button in the dialog
87
+ * @param buttonText Optional specific button text, defaults to common submit button selectors
88
+ */
89
+ async submitDialog(buttonText?: string): Promise<void> {
90
+ if (buttonText) {
91
+ const button = this.page
92
+ .locator('.v-dialog button')
93
+ .filter({ hasText: buttonText })
94
+ .first();
95
+ await button.click();
96
+ return;
97
+ }
98
+
99
+ // Try common submit button selectors
100
+ const submitSelectors = [
101
+ '[data-testid="dialog-submit"]',
102
+ '[data-testid="dialog-confirm"]',
103
+ '[data-testid="submit-dialog"]',
104
+ '.v-dialog button[type="submit"]',
105
+ ];
106
+
107
+ for (const selector of submitSelectors) {
108
+ const button = this.page.locator(selector).first();
109
+ if (await button.isVisible({ timeout: 1000 }).catch(() => false)) {
110
+ await button.click();
111
+ return;
112
+ }
113
+ }
114
+
115
+ // Fallback: look for primary button
116
+ const primaryButton = this.page
117
+ .locator('.v-dialog .v-btn--variant-elevated')
118
+ .last();
119
+ if (await primaryButton.isVisible()) {
120
+ await primaryButton.click();
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Click the cancel button in the dialog
126
+ */
127
+ async cancelDialog(): Promise<void> {
128
+ const cancelSelectors = [
129
+ '[data-testid="dialog-cancel"]',
130
+ '[data-testid="cancel-dialog"]',
131
+ '.v-dialog button:has-text("Cancel")',
132
+ ];
133
+
134
+ for (const selector of cancelSelectors) {
135
+ const button = this.page.locator(selector).first();
136
+ if (await button.isVisible({ timeout: 1000 }).catch(() => false)) {
137
+ await button.click();
138
+ return;
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Get dialog title text
145
+ */
146
+ async getDialogTitle(): Promise<string> {
147
+ const titleSelectors = [
148
+ '.v-dialog .v-card-title',
149
+ '.v-dialog .v-toolbar-title',
150
+ '.v-dialog h2',
151
+ '.v-dialog h3',
152
+ ];
153
+
154
+ for (const selector of titleSelectors) {
155
+ const title = this.page.locator(selector).first();
156
+ if (await title.isVisible({ timeout: 1000 }).catch(() => false)) {
157
+ return await title.textContent() || '';
158
+ }
159
+ }
160
+
161
+ return '';
162
+ }
163
+
164
+ /**
165
+ * Check if dialog is currently open
166
+ */
167
+ async isDialogOpen(selector?: string): Promise<boolean> {
168
+ const dialogSelector = selector || '.v-dialog--active';
169
+ return await this.page
170
+ .locator(dialogSelector)
171
+ .isVisible()
172
+ .catch(() => false);
173
+ }
174
+
175
+ /**
176
+ * Wait for dialog content to be fully loaded
177
+ * Waits for network idle and any loading spinners to disappear
178
+ */
179
+ async waitForDialogContentReady(timeout = 5000): Promise<void> {
180
+ // Wait for any loading spinners to disappear
181
+ await this.page
182
+ .locator('.v-dialog .v-progress-circular')
183
+ .waitFor({ state: 'hidden', timeout })
184
+ .catch(() => {});
185
+
186
+ // Wait a bit for animations
187
+ await this.page.waitForTimeout(300);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Create a dialog helper instance for a page
193
+ */
194
+ export function createDialogHelper(page: Page): DialogHelper {
195
+ return new DialogHelper(page);
196
+ }
@@ -0,0 +1,207 @@
1
+ import { Page, Locator } from '@playwright/test';
2
+
3
+ /**
4
+ * Helper utilities for filling forms
5
+ * Provides methods to fill common form fields used in FDM Monster
6
+ */
7
+
8
+ export class FormHelper {
9
+ constructor(private page: Page) {}
10
+
11
+ /**
12
+ * Fill a text input field
13
+ * @param selector Field selector or label text
14
+ * @param value Value to fill
15
+ */
16
+ async fillTextField(selector: string, value: string): Promise<void> {
17
+ const input = this.page.locator(selector).first();
18
+ await input.clear();
19
+ await input.fill(value);
20
+ }
21
+
22
+ /**
23
+ * Fill printer form fields
24
+ */
25
+ async fillPrinterForm(data: {
26
+ name?: string;
27
+ printerURL?: string;
28
+ apiKey?: string;
29
+ printerType?: number;
30
+ username?: string;
31
+ password?: string;
32
+ }): Promise<void> {
33
+ if (data.name !== undefined) {
34
+ await this.fillTextField('[name="name"]', data.name);
35
+ }
36
+ if (data.printerURL !== undefined) {
37
+ await this.fillTextField('[name="printerURL"]', data.printerURL);
38
+ }
39
+ if (data.apiKey !== undefined) {
40
+ await this.fillTextField('[name="apiKey"]', data.apiKey);
41
+ }
42
+ if (data.username !== undefined) {
43
+ await this.fillTextField('[name="username"]', data.username);
44
+ }
45
+ if (data.password !== undefined) {
46
+ await this.fillTextField('[name="password"]', data.password);
47
+ }
48
+ if (data.printerType !== undefined) {
49
+ await this.selectDropdownOption('[name="printerType"]', data.printerType.toString());
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Fill floor form fields
55
+ */
56
+ async fillFloorForm(data: { name?: string; order?: string }): Promise<void> {
57
+ if (data.name !== undefined) {
58
+ await this.fillTextField('[name="name"]', data.name);
59
+ }
60
+ if (data.order !== undefined) {
61
+ await this.fillTextField('[name="order"]', data.order);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Fill camera form fields
67
+ */
68
+ async fillCameraForm(data: {
69
+ name?: string;
70
+ streamURL?: string;
71
+ printerId?: number;
72
+ }): Promise<void> {
73
+ if (data.name !== undefined) {
74
+ await this.fillTextField('[name="name"]', data.name);
75
+ }
76
+ if (data.streamURL !== undefined) {
77
+ await this.fillTextField('[name="streamURL"]', data.streamURL);
78
+ }
79
+ if (data.printerId !== undefined) {
80
+ await this.selectDropdownOption('[name="printerId"]', data.printerId.toString());
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Fill login form
86
+ */
87
+ async fillLoginForm(username: string, password: string): Promise<void> {
88
+ await this.fillTextField('[name="username"]', username);
89
+ await this.fillTextField('[name="password"]', password);
90
+ }
91
+
92
+ /**
93
+ * Fill registration form
94
+ */
95
+ async fillRegistrationForm(
96
+ username: string,
97
+ password: string,
98
+ confirmPassword?: string
99
+ ): Promise<void> {
100
+ await this.fillTextField('[name="username"]', username);
101
+ await this.fillTextField('[name="password"]', password);
102
+ if (confirmPassword !== undefined) {
103
+ await this.fillTextField('[name="confirmPassword"]', confirmPassword);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Select option from dropdown (Vuetify select)
109
+ * @param selector Select field selector
110
+ * @param value Value or visible text to select
111
+ */
112
+ async selectDropdownOption(selector: string, value: string): Promise<void> {
113
+ // Click to open the select
114
+ const select = this.page.locator(selector).first();
115
+ await select.click();
116
+
117
+ // Wait for menu to appear
118
+ await this.page.waitForSelector('.v-menu--active', { state: 'visible' });
119
+
120
+ // Try to click by value or text
121
+ const option = this.page
122
+ .locator('.v-menu--active .v-list-item')
123
+ .filter({ hasText: value })
124
+ .first();
125
+
126
+ if (await option.isVisible()) {
127
+ await option.click();
128
+ } else {
129
+ // Fallback: click first option if no match found
130
+ await this.page.locator('.v-menu--active .v-list-item').first().click();
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Toggle a checkbox
136
+ * @param selector Checkbox selector
137
+ * @param checked Desired state (true for checked, false for unchecked)
138
+ */
139
+ async toggleCheckbox(selector: string, checked: boolean): Promise<void> {
140
+ const checkbox = this.page.locator(selector).first();
141
+ const isChecked = await checkbox.isChecked();
142
+
143
+ if (isChecked !== checked) {
144
+ await checkbox.click();
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Toggle a switch (Vuetify switch)
150
+ * @param selector Switch selector
151
+ * @param enabled Desired state (true for on, false for off)
152
+ */
153
+ async toggleSwitch(selector: string, enabled: boolean): Promise<void> {
154
+ const switchElement = this.page.locator(selector).first();
155
+ const isEnabled = await switchElement.getAttribute('aria-checked');
156
+
157
+ if ((isEnabled === 'true') !== enabled) {
158
+ await switchElement.click();
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Wait for form validation to complete
164
+ * Waits for error messages to appear or disappear
165
+ */
166
+ async waitForValidation(timeout = 2000): Promise<void> {
167
+ await this.page.waitForTimeout(500);
168
+ // Wait for any validation messages to settle
169
+ await this.page
170
+ .locator('.v-messages__message')
171
+ .first()
172
+ .waitFor({ state: 'visible', timeout })
173
+ .catch(() => {});
174
+ }
175
+
176
+ /**
177
+ * Get validation error message for a field
178
+ * @param fieldSelector Field selector
179
+ */
180
+ async getFieldError(fieldSelector: string): Promise<string | null> {
181
+ const field = this.page.locator(fieldSelector).first();
182
+ const errorMessage = field
183
+ .locator('.. >> .v-messages__message')
184
+ .first();
185
+
186
+ if (await errorMessage.isVisible().catch(() => false)) {
187
+ return await errorMessage.textContent();
188
+ }
189
+
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * Check if form has any validation errors
195
+ */
196
+ async hasValidationErrors(): Promise<boolean> {
197
+ const errorMessages = this.page.locator('.v-messages__message.error--text');
198
+ return (await errorMessages.count()) > 0;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Create a form helper instance for a page
204
+ */
205
+ export function createFormHelper(page: Page): FormHelper {
206
+ return new FormHelper(page);
207
+ }
@@ -0,0 +1,191 @@
1
+ import { Page } from '@playwright/test';
2
+
3
+ /**
4
+ * Helper utilities for navigation and routing
5
+ */
6
+
7
+ export class NavigationHelper {
8
+ constructor(private page: Page) {}
9
+
10
+ /**
11
+ * Navigate to a specific route
12
+ * @param path Route path (e.g., '/dashboard', '/printer-grid')
13
+ */
14
+ async navigateTo(path: string): Promise<void> {
15
+ await this.page.goto(path);
16
+ await this.page.waitForLoadState('networkidle');
17
+ }
18
+
19
+ /**
20
+ * Navigate to dashboard
21
+ */
22
+ async goToDashboard(): Promise<void> {
23
+ await this.navigateTo('/dashboard');
24
+ }
25
+
26
+ /**
27
+ * Navigate to printer grid
28
+ */
29
+ async goToPrinterGrid(): Promise<void> {
30
+ await this.navigateTo('/printer-grid');
31
+ }
32
+
33
+ /**
34
+ * Navigate to printer list
35
+ */
36
+ async goToPrinterList(): Promise<void> {
37
+ await this.navigateTo('/printer-list');
38
+ }
39
+
40
+ /**
41
+ * Navigate to camera grid
42
+ */
43
+ async goToCameras(): Promise<void> {
44
+ await this.navigateTo('/cameras');
45
+ }
46
+
47
+ /**
48
+ * Navigate to print jobs
49
+ */
50
+ async goToPrintJobs(): Promise<void> {
51
+ await this.navigateTo('/print-jobs');
52
+ }
53
+
54
+ /**
55
+ * Navigate to print queue
56
+ */
57
+ async goToPrintQueue(): Promise<void> {
58
+ await this.navigateTo('/queue');
59
+ }
60
+
61
+ /**
62
+ * Navigate to files
63
+ */
64
+ async goToFiles(): Promise<void> {
65
+ await this.navigateTo('/files');
66
+ }
67
+
68
+ /**
69
+ * Navigate to settings
70
+ * @param subsection Optional settings subsection (e.g., 'server', 'users', 'floors')
71
+ */
72
+ async goToSettings(subsection?: string): Promise<void> {
73
+ if (subsection) {
74
+ await this.navigateTo(`/settings/${subsection}`);
75
+ } else {
76
+ await this.navigateTo('/settings');
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Navigate to login page
82
+ */
83
+ async goToLogin(): Promise<void> {
84
+ await this.navigateTo('/login');
85
+ }
86
+
87
+ /**
88
+ * Navigate to registration page
89
+ */
90
+ async goToRegister(): Promise<void> {
91
+ await this.navigateTo('/registration');
92
+ }
93
+
94
+ /**
95
+ * Navigate to first time setup
96
+ */
97
+ async goToFirstTimeSetup(): Promise<void> {
98
+ await this.navigateTo('/first-time-setup');
99
+ }
100
+
101
+ /**
102
+ * Open sidebar/navigation drawer (if collapsed on mobile/tablet)
103
+ */
104
+ async openSidebar(): Promise<void> {
105
+ const sidebarToggle = this.page.locator('[data-testid="sidebar-toggle"]');
106
+ const navigationDrawer = this.page.locator('.v-navigation-drawer');
107
+
108
+ // Check if sidebar is hidden (mobile view)
109
+ const isHidden = await navigationDrawer.isHidden().catch(() => true);
110
+
111
+ if (isHidden && (await sidebarToggle.isVisible())) {
112
+ await sidebarToggle.click();
113
+ await this.page.waitForSelector('.v-navigation-drawer', {
114
+ state: 'visible',
115
+ });
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Click a navigation link in the sidebar
121
+ * @param linkText Text of the navigation link
122
+ */
123
+ async clickSidebarLink(linkText: string): Promise<void> {
124
+ await this.openSidebar();
125
+
126
+ const link = this.page
127
+ .locator('.v-navigation-drawer a, .v-navigation-drawer .v-list-item')
128
+ .filter({ hasText: linkText })
129
+ .first();
130
+
131
+ await link.click();
132
+ await this.page.waitForLoadState('networkidle');
133
+ }
134
+
135
+ /**
136
+ * Wait for page to be fully loaded
137
+ * Waits for network idle and any loading indicators to disappear
138
+ */
139
+ async waitForPageLoad(timeout = 5000): Promise<void> {
140
+ await this.page.waitForLoadState('networkidle');
141
+
142
+ // Wait for any loading spinners to disappear
143
+ await this.page
144
+ .locator('.v-progress-circular, .v-progress-linear')
145
+ .first()
146
+ .waitFor({ state: 'hidden', timeout })
147
+ .catch(() => {});
148
+
149
+ // Small delay for animations
150
+ await this.page.waitForTimeout(300);
151
+ }
152
+
153
+ /**
154
+ * Get current route path
155
+ */
156
+ async getCurrentPath(): Promise<string> {
157
+ return await this.page.evaluate(() => window.location.pathname);
158
+ }
159
+
160
+ /**
161
+ * Check if currently on a specific route
162
+ * @param path Route path to check
163
+ */
164
+ async isOnRoute(path: string): Promise<boolean> {
165
+ const currentPath = await this.getCurrentPath();
166
+ return currentPath === path;
167
+ }
168
+
169
+ /**
170
+ * Go back to previous page
171
+ */
172
+ async goBack(): Promise<void> {
173
+ await this.page.goBack();
174
+ await this.waitForPageLoad();
175
+ }
176
+
177
+ /**
178
+ * Refresh current page
179
+ */
180
+ async refresh(): Promise<void> {
181
+ await this.page.reload();
182
+ await this.waitForPageLoad();
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Create a navigation helper instance for a page
188
+ */
189
+ export function createNavigationHelper(page: Page): NavigationHelper {
190
+ return new NavigationHelper(page);
191
+ }