Skip to content

Commit f0df946

Browse files
committed
Merge branch 'main' into 26.3
2 parents 416312c + 0791088 commit f0df946

26 files changed

Lines changed: 31978 additions & 4 deletions

e2e/E2E_COVERAGE_REPORT.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
| Start Page | `/start` | 8 | 6 | 🔶 75% |
2121
| Dashboard | `/dashboard` | 9 | 7 | 🔶 78% |
2222
| Session List | `/session` | 20 | 12 | 🔶 60% |
23-
| Session Launcher | `/session/start` | 12 | 1 | 🔶 8% |
23+
| Session Launcher | `/session/start` | 12 | 2 | 🔶 17% |
2424
| Serving | `/serving` | 7 | 0 | ❌ 0% |
2525
| Endpoint Detail | `/serving/:serviceId` | 20 | 9 | 🔶 45% |
2626
| Service Launcher | `/service/start` | 5 | 0 | ❌ 0% |
@@ -166,7 +166,7 @@
166166

167167
### 5. Session Launcher (`/session/start`)
168168

169-
**Test files:** Covered indirectly via [`e2e/session/session-creation.spec.ts`](session/session-creation.spec.ts)
169+
**Test files:** Covered indirectly via [`e2e/session/session-creation.spec.ts`](session/session-creation.spec.ts), [`e2e/session/session-template-modal.spec.ts`](session/session-template-modal.spec.ts)
170170

171171
**Steps:** 1.Session Type → 2.Environments & Resource → 3.Data & Storage → 4.Network → 5.Confirm
172172
**Modals:** `SessionTemplateModal` (recent history)
@@ -184,9 +184,9 @@
184184
| Batch schedule/timeout options || - |
185185
| Session owner selection (admin) || - |
186186
| Form validation errors || - |
187-
| Session history → SessionTemplateModal | | - |
187+
| Session history → SessionTemplateModal | | `session-template-modal.spec.ts` (7 tests) |
188188

189-
**Coverage: 🔶 1/12 features (most only indirectly tested)**
189+
**Coverage: 🔶 2/12 features (most only indirectly tested)**
190190

191191
---
192192

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
// E2E spec: Session Template Modal – Multi-Node Indicator & Real Session History
2+
import { SessionLauncher } from '../utils/classes/session/SessionLauncher';
3+
import { loginAsAdmin, loginAsUser, navigateTo } from '../utils/test-util';
4+
import { Page } from '@playwright/test';
5+
import { test, expect } from '@playwright/test';
6+
7+
// ---------------------------------------------------------------------------
8+
// Helpers
9+
// ---------------------------------------------------------------------------
10+
11+
/**
12+
* Encode a form-values object into the URLSearchParams string format used by
13+
* the session history `params` field.
14+
*/
15+
function buildParams(formValues: Record<string, unknown>): string {
16+
const p = new URLSearchParams();
17+
p.set('formValues', JSON.stringify(formValues));
18+
return p.toString();
19+
}
20+
21+
/**
22+
* Seed localStorage with session history that contains both a multi-node
23+
* session and a single-node session, then reload so the app picks them up.
24+
*/
25+
async function seedSessionHistory(page: import('@playwright/test').Page) {
26+
const STORAGE_KEY = 'backendaiwebui.settings.user.recentSessionHistory';
27+
28+
const multiNodeEntry = {
29+
id: 'e2e-multi-node-id',
30+
name: 'multi-node-session',
31+
params: buildParams({
32+
cluster_mode: 'multi-node',
33+
cluster_size: 4,
34+
sessionName: 'multi-node-session',
35+
}),
36+
createdAt: new Date(Date.now() - 1000).toISOString(),
37+
};
38+
39+
const singleNodeEntry = {
40+
id: 'e2e-single-node-id',
41+
name: 'single-node-session',
42+
params: buildParams({
43+
cluster_mode: 'single-node',
44+
cluster_size: 1,
45+
sessionName: 'single-node-session',
46+
}),
47+
createdAt: new Date(Date.now() - 2000).toISOString(),
48+
};
49+
50+
await page.evaluate(
51+
({ key, value }: { key: string; value: string }) => {
52+
localStorage.setItem(key, value);
53+
},
54+
{
55+
key: STORAGE_KEY,
56+
value: JSON.stringify([multiNodeEntry, singleNodeEntry]),
57+
},
58+
);
59+
60+
// Reload so Jotai atoms are initialised from the updated localStorage
61+
await page.reload({ waitUntil: 'domcontentloaded' });
62+
}
63+
64+
/**
65+
* Opens the Session Template Modal by clicking the "Recent History" button on
66+
* the Session Launcher page and waits for the modal to become visible.
67+
* Returns the modal locator.
68+
*/
69+
async function openTemplateModal(page: import('@playwright/test').Page) {
70+
const recentHistoryButton = page.getByRole('button', {
71+
name: 'Recent History',
72+
});
73+
await expect(recentHistoryButton).toBeVisible({ timeout: 10_000 });
74+
await recentHistoryButton.click();
75+
76+
const modal = page.getByRole('dialog', { name: 'Recent History' });
77+
await expect(modal).toBeVisible({ timeout: 10_000 });
78+
return modal;
79+
}
80+
81+
// ---------------------------------------------------------------------------
82+
// Tests: Multi-Node Indicator (using seeded localStorage)
83+
// ---------------------------------------------------------------------------
84+
85+
test.describe(
86+
'Session Template Modal – Multi-Node Indicator',
87+
{ tag: ['@session', '@functional', '@regression'] },
88+
() => {
89+
test.describe.configure({ mode: 'serial' });
90+
test.setTimeout(60_000);
91+
92+
test.beforeEach(async ({ page, request }) => {
93+
await loginAsAdmin(page, request);
94+
await navigateTo(page, 'session/start');
95+
await expect(
96+
page.getByRole('button', { name: 'Recent History' }),
97+
).toBeVisible({ timeout: 15_000 });
98+
// Seed session history via localStorage then reload
99+
await seedSessionHistory(page);
100+
// After reload, navigate back to session/start
101+
await navigateTo(page, 'session/start');
102+
await expect(
103+
page.getByRole('button', { name: 'Recent History' }),
104+
).toBeVisible({ timeout: 15_000 });
105+
});
106+
107+
test('User can open the Session Template Modal from the Recent History button', async ({
108+
page,
109+
}) => {
110+
const modal = await openTemplateModal(page);
111+
await expect(modal).toBeVisible();
112+
});
113+
114+
test('Multi-node sessions show a Multi Node badge next to the session name', async ({
115+
page,
116+
}) => {
117+
const modal = await openTemplateModal(page);
118+
119+
const multiNodeRow = modal
120+
.getByRole('row')
121+
.filter({ hasText: 'multi-node-session' });
122+
await expect(multiNodeRow).toBeVisible({ timeout: 10_000 });
123+
124+
const multiNodeBadge = multiNodeRow.getByText(/Multi Node/);
125+
await expect(multiNodeBadge).toBeVisible();
126+
});
127+
128+
test('Multi Node badge displays the cluster size (node count)', async ({
129+
page,
130+
}) => {
131+
const modal = await openTemplateModal(page);
132+
133+
const multiNodeRow = modal
134+
.getByRole('row')
135+
.filter({ hasText: 'multi-node-session' });
136+
await expect(multiNodeRow).toBeVisible({ timeout: 10_000 });
137+
138+
// Badge text is "Multi Node ×4"
139+
const multiNodeBadge = multiNodeRow.getByText(/Multi Node.*×.*4/);
140+
await expect(multiNodeBadge).toBeVisible();
141+
});
142+
143+
test('Single-node sessions do not show a Multi Node badge', async ({
144+
page,
145+
}) => {
146+
const modal = await openTemplateModal(page);
147+
148+
const singleNodeRow = modal
149+
.getByRole('row')
150+
.filter({ hasText: 'single-node-session' });
151+
await expect(singleNodeRow).toBeVisible({ timeout: 10_000 });
152+
153+
await expect(singleNodeRow.getByText(/Multi Node/)).toHaveCount(0);
154+
});
155+
156+
test('Clicking a session name in the modal closes the modal and applies the template', async ({
157+
page,
158+
}) => {
159+
const modal = await openTemplateModal(page);
160+
161+
const multiNodeRow = modal
162+
.getByRole('row')
163+
.filter({ hasText: 'multi-node-session' });
164+
await expect(multiNodeRow).toBeVisible({ timeout: 10_000 });
165+
166+
await multiNodeRow.getByText('multi-node-session').click();
167+
168+
await expect(modal).not.toBeVisible({ timeout: 5_000 });
169+
});
170+
171+
test('User can close the Session Template Modal using the X button', async ({
172+
page,
173+
}) => {
174+
const modal = await openTemplateModal(page);
175+
await expect(modal).toBeVisible();
176+
177+
await modal.getByRole('button', { name: 'Close' }).first().click();
178+
179+
await expect(modal).not.toBeVisible({ timeout: 5_000 });
180+
});
181+
},
182+
);
183+
184+
// ---------------------------------------------------------------------------
185+
// Helpers: Real session creation (follows proven pattern from session-creation.spec.ts)
186+
// ---------------------------------------------------------------------------
187+
188+
const createInteractiveSession = async (page: Page, sessionName: string) => {
189+
const interactiveRadioButton = page
190+
.locator('label')
191+
.filter({ hasText: 'Interactive' })
192+
.locator('input[type="radio"]');
193+
await interactiveRadioButton.check();
194+
const sessionNameInput = page.locator('#sessionName');
195+
await sessionNameInput.fill(sessionName);
196+
await page.getByRole('button', { name: 'Next' }).click();
197+
198+
// Wait for resource group combobox to be visible (indicates page is ready)
199+
const resourceGroup = page.getByRole('combobox', {
200+
name: 'Resource Group',
201+
});
202+
await expect(resourceGroup).toBeVisible({ timeout: 10_000 });
203+
await resourceGroup.fill('default');
204+
await page.keyboard.press('Enter');
205+
// Select Minimum Requirements
206+
const resourcePreset = page.getByRole('combobox', {
207+
name: 'Resource Presets',
208+
});
209+
await expect(resourcePreset).toBeVisible();
210+
await resourcePreset.fill('minimum');
211+
await page.getByRole('option', { name: 'minimum' }).click();
212+
// Launch
213+
await page.getByRole('button', { name: 'Skip to review' }).click();
214+
215+
// Wait for Launch button to be enabled
216+
const launchButton = page.locator('button').filter({ hasText: 'Launch' });
217+
await expect(launchButton).toBeEnabled({ timeout: 10_000 });
218+
await launchButton.click();
219+
220+
await page
221+
.getByRole('dialog')
222+
.filter({ hasText: 'No storage folder is mounted' })
223+
.getByRole('button', { name: 'Start' })
224+
.click();
225+
};
226+
227+
// ---------------------------------------------------------------------------
228+
// Tests: Real session creation → session history → re-create from template
229+
// ---------------------------------------------------------------------------
230+
231+
test.describe(
232+
'Session Template Modal – Real Session History',
233+
{ tag: ['@session', '@functional'] },
234+
() => {
235+
test.setTimeout(180_000);
236+
237+
let createdSessionName: string | null = null;
238+
239+
test.afterEach(async ({ page }) => {
240+
// Cleanup: terminate created session if it exists
241+
if (createdSessionName) {
242+
try {
243+
const sessionLauncher = new SessionLauncher(page);
244+
sessionLauncher.withSessionName(createdSessionName);
245+
await sessionLauncher.terminate();
246+
} catch (error) {
247+
console.log(
248+
`Failed to terminate session ${createdSessionName}:`,
249+
error,
250+
);
251+
}
252+
}
253+
});
254+
255+
test('Created session appears in Recent History and can be re-launched from template', async ({
256+
page,
257+
request,
258+
}) => {
259+
await loginAsUser(page, request);
260+
261+
// Step 1: Navigate to session launcher and create a real session
262+
const sessionName = `e2e-template-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
263+
createdSessionName = sessionName;
264+
265+
await navigateTo(page, 'session/start');
266+
// Wait for the session launcher page to be ready
267+
await expect(
268+
page.locator('label').filter({ hasText: 'Interactive' }),
269+
).toBeVisible({ timeout: 15_000 });
270+
271+
await createInteractiveSession(page, sessionName);
272+
273+
// Wait for the app to navigate to /session after session creation.
274+
// This confirms that pushSessionHistory has been called (it runs before
275+
// webuiNavigate('/job') which then redirects to /session).
276+
// Without this wait, navigateTo(page, 'session/start') could fire before
277+
// the async startSession API resolves, leaving localStorage empty.
278+
await page.waitForURL(/\/session(?!\/start)/, { timeout: 30_000 });
279+
280+
// Verify session appears in session list
281+
const sessionRow = page.locator('tr').filter({ hasText: sessionName });
282+
await expect(sessionRow).toBeVisible({ timeout: 30_000 });
283+
284+
// Step 2: Navigate back to session/start and open Recent History modal
285+
await navigateTo(page, 'session/start');
286+
await expect(
287+
page.getByRole('button', { name: 'Recent History' }),
288+
).toBeVisible({ timeout: 15_000 });
289+
290+
const modal = await openTemplateModal(page);
291+
292+
// Step 3: Verify the created session appears in the modal
293+
const historyRow = modal
294+
.getByRole('row')
295+
.filter({ hasText: sessionName });
296+
await expect(historyRow).toBeVisible({ timeout: 10_000 });
297+
298+
// Step 4: Click the session name to apply the template
299+
await historyRow.getByText(sessionName).click();
300+
301+
// Modal should close after selecting a history entry
302+
await expect(modal).not.toBeVisible({ timeout: 5_000 });
303+
304+
// Step 5: Verify the template was applied to the form.
305+
// After clicking a history entry, the form navigates to the review step
306+
// (last step) and populates the form fields. The #sessionName input is
307+
// on step 1 which is not the current active step, so it may be hidden.
308+
// We verify the template was applied by checking the "Confirm and Launch"
309+
// step is now active and the URL contains formValues with a sessionName.
310+
await expect(
311+
page.getByRole('button', { name: /Confirm and Launch/i }),
312+
).toBeVisible({ timeout: 5_000 });
313+
// The URL should contain formValues with a sessionName set from the template
314+
await expect(page).toHaveURL(/formValues.*sessionName/, {
315+
timeout: 5_000,
316+
});
317+
});
318+
},
319+
);

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"dotenv": "^16.6.1",
4040
"https-proxy-agent": "^7.0.6",
4141
"mime-types": "^2.1.35",
42+
"octokit": "^5.0.5",
4243
"utf-8-validate": "^6.0.6",
4344
"winston": "^3.19.0"
4445
},

0 commit comments

Comments
 (0)