From 0cdb3a676ffca5d864bca9f2b9a822afb2728a8c Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 13 Nov 2026 12:28:30 +0200 Subject: [PATCH 1/3] feat(account): move remote-access instructions to their own page Keep the profile page to the access token and a link out to /account/remote-access, with Linux/macOS and Windows command variants. --- src/app/app.routes.ts | 5 + .../account-page/account-page.component.css | 50 ++++---- .../account-page/account-page.component.html | 109 +++++----------- .../account-page.component.spec.ts | 64 +++------- .../account-page/account-page.component.ts | 19 +-- .../pages/account-page/copy-to-clipboard.ts | 13 ++ .../remote-access-guide.component.css | 120 ++++++++++++++++++ .../remote-access-guide.component.html | 88 +++++++++++++ .../remote-access-guide.component.spec.ts | 100 +++++++++++++++ .../remote-access-guide.component.ts | 43 +++++++ 10 files changed, 447 insertions(+), 164 deletions(-) create mode 100644 src/app/pages/account-page/copy-to-clipboard.ts create mode 100644 src/app/pages/account-page/remote-access-guide/remote-access-guide.component.css create mode 100644 src/app/pages/account-page/remote-access-guide/remote-access-guide.component.html create mode 100644 src/app/pages/account-page/remote-access-guide/remote-access-guide.component.spec.ts create mode 100644 src/app/pages/account-page/remote-access-guide/remote-access-guide.component.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 816a2e79f..427ac7c87 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -29,6 +29,11 @@ export const appRoutes: Routes = [ loadComponent: () => import('./pages/account-page/account-page.component').then(m => m.AccountPageComponent), canActivate: [AuthGuard, TermsGuard], }, + { + path: 'account/remote-access', + loadComponent: () => import('./pages/account-page/remote-access-guide/remote-access-guide.component').then(m => m.RemoteAccessGuideComponent), + canActivate: [AuthGuard, TermsGuard], + }, { path: 'experiment-studio', loadComponent: () => import('./pages/experiment-studio/experiment-studio.component').then(m => m.ExperimentStudioComponent), diff --git a/src/app/pages/account-page/account-page.component.css b/src/app/pages/account-page/account-page.component.css index 8ae22fcc6..4788ad589 100644 --- a/src/app/pages/account-page/account-page.component.css +++ b/src/app/pages/account-page/account-page.component.css @@ -518,9 +518,6 @@ font-size: 0.92em; } -.remote-text { - margin: 14px 0 8px; -} .remote-note { margin: 6px 0 0; @@ -532,32 +529,10 @@ color: var(--primary-color); } -.remote-options { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); - gap: 12px 16px; -} -.remote-option { - padding: 14px 16px; - border-radius: 10px; - background: #f9fafb; - border: 1px solid #e5e7eb; - min-width: 0; -} -.remote-option pre { - margin-top: 10px; - background: #ffffff; -} -.remote-option :not(pre) > code { - background: #e5e7eb; -} -.remote-option .remote-text { - margin: 10px 0 0; -} .remote-access h4 { margin: 0 0 10px; @@ -614,6 +589,7 @@ font-size: 14px; font-weight: 500; cursor: pointer; + margin-top: 10px; } .create-token-btn:hover:not(:disabled) { @@ -634,3 +610,27 @@ margin: 8px 0 0; color: #991b1b; } + +/* Way into the setup instructions page. */ +.guide-link { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 18px; + padding: 8px 14px; + border: 1px solid var(--primary-color); + border-radius: var(--radius-md); + color: var(--primary-color); + font-size: 14px; + font-weight: 500; + text-decoration: none; +} + +.guide-link:hover { + background: color-mix(in srgb, var(--primary-color) 6%, white); +} + +.guide-link:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} diff --git a/src/app/pages/account-page/account-page.component.html b/src/app/pages/account-page/account-page.component.html index 48317ede1..9ebcdfebc 100644 --- a/src/app/pages/account-page/account-page.component.html +++ b/src/app/pages/account-page/account-page.component.html @@ -46,83 +46,42 @@

Profile Information

@if (authConfig()) {
-

Use MIP from your own computer

- -

Install the client and point it at this portal:

-
-
{{ setupSnippet }}
- -
- {{ copyStatus() }} -

In Windows PowerShell, write $env:MIP_URL = "…" instead of export.

- -

Then sign in, one of two ways:

-
-
-

No browser on that machine

-

- This token works like your password for up to 180 days. Keep it only in - mip login --token; never put it in notebooks, .env files, - repositories or chat. Lost it or your computer? - . -

- -

You confirm with your MIP sign-in. The token is shown only once.

- @if (tokenError()) { - - } - @if (cliToken()) { -
-
{{ cliToken() }}
- -
- {{ tokenCopyStatus() }} -

- It lasts up to 180 days, or until it is unused for 30 days, or until you run - mip logout. Lost it or your computer? - . -

- } -

- On that machine, run mip login --token and paste it. Scripts can set - MIP_API_TOKEN instead, but every program started from there, AI assistants - included, can read it. -

-
- -
-

A browser on any device

-

- Run mip login. It shows a link and a code: open the link, sign in, check - that the page shows the same code, and approve. -

-

Never approve a code that someone else sent you.

-
-
- -

Then, in Python:

-
import mip
-client = mip.Client.from_env()
+

Access token

- Each sign-in lasts at most 180 days, and ends after 30 days without use. To end it sooner, - run mip logout on that computer. Without that computer, remove - "MIP command line" under Applications in your - : that ends every computer's sign-in. + Signs in the MIP client on a computer without a browser. This token works like your + password for up to 180 days. Keep it only in mip login --token; never put it + in notebooks, .env files, repositories or chat.

+ +

You confirm with your MIP sign-in. The token is shown only once.

+ @if (tokenError()) { + + } + @if (cliToken()) { +
+
{{ cliToken() }}
+ +
+ {{ tokenCopyStatus() }} +

+ It lasts up to 180 days, or until it is unused for 30 days, or until you run + mip logout. Lost it or your computer? + . +

+ } + + + Setup instructions + +
} diff --git a/src/app/pages/account-page/account-page.component.spec.ts b/src/app/pages/account-page/account-page.component.spec.ts index 8aef14536..a89271459 100644 --- a/src/app/pages/account-page/account-page.component.spec.ts +++ b/src/app/pages/account-page/account-page.component.spec.ts @@ -50,27 +50,28 @@ describe('AccountPageComponent', () => { expect(el.querySelector('.remote-access')).toBeNull(); }); - it('explains both ways to sign in and never sends users to the JupyterHub token page', () => { + it('keeps only the access token and a way to the setup instructions, never the JupyterHub token page', () => { const el: HTMLElement = render().nativeElement; - const text = el.querySelector('.remote-access')!.textContent!; - expect(text).toContain('mip login --token'); - expect(text).toContain('MIP_API_TOKEN'); - expect(text).toContain('mip logout'); - expect(text).not.toContain('custom:mip-platform-token'); + const section = el.querySelector('.remote-access')!; + expect(section.querySelector('h3')!.textContent).toBe('Access token'); + expect(section.querySelector('.create-token-btn')).not.toBeNull(); + expect(section.querySelector('.guide-link')!.getAttribute('href')).toBe('/account/remote-access'); + expect(section.textContent).not.toContain('pip install'); + expect(section.textContent).not.toContain('custom:mip-platform-token'); expect(el.querySelector('a[href*="/hub/token"]')).toBeNull(); expect(el.querySelector('.token-value')).toBeNull(); }); - it('links to the Keycloak applications page wherever it offers to remove computers', () => { - const links = (render().nativeElement as HTMLElement).querySelectorAll('.account-link'); + it('links to the Keycloak applications page next to the shown token', () => { + const fixture = render(); + fixture.componentInstance.cliToken.set('offline-token'); + fixture.detectChanges(); - expect(links.length).toBeGreaterThanOrEqual(2); - links.forEach((link) => { - expect(link.getAttribute('href')).toBe('https://idp.example/realms/MIP/account/applications'); - expect(link.target).toBe('_blank'); - expect(link.rel).toBe('noopener'); - }); + const link = (fixture.nativeElement as HTMLElement).querySelector('a.account-link')!; + expect(link.getAttribute('href')).toBe('https://idp.example/realms/MIP/account/applications'); + expect(link.target).toBe('_blank'); + expect(link.rel).toBe('noopener'); }); it('warns above the button that the token is a 180-day password kept only in mip login --token', () => { @@ -181,39 +182,4 @@ describe('AccountPageComponent', () => { fixture.detectChanges(); expect(button.disabled).toBe(false); }); - - it('copies a setup snippet built from the current origin, with no real token', async () => { - const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); - const fixture = render(); - - (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click(); - await fixture.whenStable(); - - expect(writeText).toHaveBeenCalledWith( - `pip install mip-client\nexport MIP_URL=${window.location.origin}`, - ); - expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Copied'); - }); - - it('clears the copy status so the next copy is announced again', async () => { - vi.useFakeTimers(); - vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); - const fixture = render(); - - const page = fixture.componentInstance; - await page.copy(page.setupSnippet, page.copyStatus); - expect(page.copyStatus()).toBe('Copied'); - vi.advanceTimersByTime(2400); - expect(page.copyStatus()).toBe(''); - }); - - it('tells the user to copy by hand when the clipboard refuses', async () => { - vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied')); - const fixture = render(); - - (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click(); - await fixture.whenStable(); - - expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Could not copy'); - }); }); diff --git a/src/app/pages/account-page/account-page.component.ts b/src/app/pages/account-page/account-page.component.ts index 945f78f27..015aac4c5 100644 --- a/src/app/pages/account-page/account-page.component.ts +++ b/src/app/pages/account-page/account-page.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, OnDestroy, computed, inject, signal, WritableSignal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnDestroy, computed, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule, Location } from '@angular/common'; import { HttpClient } from '@angular/common/http'; @@ -6,6 +6,7 @@ import { RouterModule } from '@angular/router'; import { catchError, of } from 'rxjs'; import { AuthService } from "../../services/auth.service"; import { AuthConfig, cliTokenSignInUrl, finishCliTokenSignIn, isSignInReturn } from './cli-token'; +import { copyToClipboard } from './copy-to-clipboard'; @Component({ selector: 'app-account-page', @@ -23,7 +24,7 @@ export class AccountPageComponent implements OnDestroy { userEmail = computed(() => this.authService.authState().user?.email ?? ''); // The platform's public Keycloak client for the mip client; the backend answers 404 - // when there is none, and the remote access guide stays hidden. + // when there is none, and the access token section stays hidden. readonly authConfig = toSignal( this.http.get('/services/auth-config').pipe(catchError(() => of(null))), { initialValue: null }, @@ -35,8 +36,7 @@ export class AccountPageComponent implements OnDestroy { readonly tokenError = signal(''); readonly creatingToken = signal(false); readonly tokenCopyStatus = signal(''); - readonly setupSnippet = ['pip install mip-client', `export MIP_URL=${window.location.origin}`].join('\n'); - readonly copyStatus = signal(''); + readonly copy = copyToClipboard; constructor() { const query = new URLSearchParams(window.location.search); @@ -83,15 +83,4 @@ export class AccountPageComponent implements OnDestroy { goBack(): void { this.location.back(); } - - async copy(text: string, status: WritableSignal): Promise { - try { - await navigator.clipboard.writeText(text); - status.set('Copied'); - } catch { - status.set('Could not copy, select the text instead'); - } - // Cleared after a while so the live region changes, and is announced, on every click. - setTimeout(() => status.set(''), 2400); - } } diff --git a/src/app/pages/account-page/copy-to-clipboard.ts b/src/app/pages/account-page/copy-to-clipboard.ts new file mode 100644 index 000000000..25c8a3c1b --- /dev/null +++ b/src/app/pages/account-page/copy-to-clipboard.ts @@ -0,0 +1,13 @@ +import { WritableSignal } from '@angular/core'; + +/** Copies text and reports the outcome in `status`, an aria-live region's text. */ +export async function copyToClipboard(text: string, status: WritableSignal): Promise { + try { + await navigator.clipboard.writeText(text); + status.set('Copied'); + } catch { + status.set('Could not copy, select the text instead'); + } + // Cleared after a while so the live region changes, and is announced, on every click. + setTimeout(() => status.set(''), 2400); +} diff --git a/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.css b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.css new file mode 100644 index 000000000..b888be5f8 --- /dev/null +++ b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.css @@ -0,0 +1,120 @@ +/* Operating system switch: one tray, the chosen system raised. */ +.platform-tabs { + display: inline-flex; + gap: 4px; + padding: 4px; + border-radius: 999px; + background: #f3f4f6; +} + +.platform-tab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border: 0; + border-radius: 999px; + background: transparent; + color: #4b5563; + font: inherit; + font-weight: 500; + cursor: pointer; +} + +.platform-tab.active { + background: #ffffff; + color: var(--primary-color); + box-shadow: var(--shadow-sm); +} + +.platform-tab:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + +/* Numbered steps; the number sits in the gutter beside each step title. */ +.guide-steps { + margin: 20px 0 0; + padding: 0; + list-style: none; + counter-reset: guide-step; +} + +.guide-steps > li { + position: relative; + padding: 0 0 24px 40px; + counter-increment: guide-step; +} + +.guide-steps > li::before { + content: counter(guide-step); + position: absolute; + left: 0; + top: 0; + width: 26px; + height: 26px; + border-radius: 50%; + background: color-mix(in srgb, var(--primary-color) 10%, white); + color: var(--primary-color); + font-size: 13px; + font-weight: 600; + line-height: 26px; + text-align: center; +} + +.guide-steps h3 { + margin: 0 0 8px; + font-size: 15px; + font-weight: 600; + line-height: 26px; + color: #111827; +} + +.guide-steps .remote-note { + margin: 0 0 8px; +} + +.guide-steps .remote-option .remote-note { + margin: 8px 0 0; +} + +.guide-footnote { + padding-top: 16px; + border-top: 1px solid #e5e7eb; +} + +a.back-btn { + text-decoration: none; +} + +/* Body text and the two sign-in options. */ +.remote-text { + margin: 14px 0 8px; +} + +.remote-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); + gap: 12px 16px; +} + +.remote-option { + padding: 14px 16px; + border-radius: 10px; + background: #f9fafb; + border: 1px solid #e5e7eb; + min-width: 0; +} + +.remote-option pre { + margin-top: 10px; + background: #ffffff; +} + +.remote-option :not(pre) > code { + background: #e5e7eb; +} + +.remote-option .remote-text { + margin: 10px 0 0; +} diff --git a/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.html b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.html new file mode 100644 index 000000000..3b3722e6b --- /dev/null +++ b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.html @@ -0,0 +1,88 @@ + diff --git a/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.spec.ts b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.spec.ts new file mode 100644 index 000000000..9472025a1 --- /dev/null +++ b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.spec.ts @@ -0,0 +1,100 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { RemoteAccessGuideComponent } from './remote-access-guide.component'; + +describe('RemoteAccessGuideComponent', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + // offered: whether the backend has a Keycloak client for `mip login` (GET /services/auth-config). + function render(offered = true) { + TestBed.configureTestingModule({ + imports: [RemoteAccessGuideComponent], + providers: [provideZonelessChangeDetection(), provideRouter([]), provideHttpClient(), provideHttpClientTesting()], + }); + const fixture = TestBed.createComponent(RemoteAccessGuideComponent); + const authConfig = TestBed.inject(HttpTestingController).expectOne('/services/auth-config'); + if (offered) { + authConfig.flush({ issuer: 'https://idp.example/realms/MIP', clientId: 'mip-cli' }); + } else { + authConfig.flush(null, { status: 404, statusText: 'Not Found' }); + } + fixture.componentInstance.platform.set('linux'); + fixture.detectChanges(); + return fixture; + } + + it('says so when the platform does not offer remote access', () => { + const el: HTMLElement = render(false).nativeElement; + + expect(el.querySelector('.guide-steps')).toBeNull(); + expect(el.textContent).toContain('does not offer access from your own computer'); + }); + + it('explains both ways to sign in and sends the user to the profile page for a token', () => { + const el: HTMLElement = render().nativeElement; + + const text = el.querySelector('.guide-steps')!.textContent!; + expect(text).toContain('mip login --token'); + expect(text).toContain('MIP_API_TOKEN'); + expect(text).toContain('mip.Client.from_env()'); + expect(el.textContent).toContain('mip logout'); + expect(el.querySelector('.remote-option a.account-link')!.getAttribute('href')).toBe('/account'); + expect(el.querySelector('.guide-footnote a')!.getAttribute('href')) + .toBe('https://idp.example/realms/MIP/account/applications'); + expect(el.querySelector('a[href*="/hub/token"]')).toBeNull(); + }); + + it('switches the commands between Linux / macOS and Windows', () => { + const fixture = render(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.snippet code')!.textContent).toContain(`export MIP_URL=${window.location.origin}`); + + const windows = Array.from(el.querySelectorAll('.platform-tab')) + .find((tab) => tab.textContent!.includes('Windows'))!; + windows.click(); + fixture.detectChanges(); + + expect(windows.getAttribute('aria-pressed')).toBe('true'); + expect(el.querySelector('.snippet code')!.textContent).toContain(`$env:MIP_URL = "${window.location.origin}"`); + expect(el.textContent).toContain('$env:MIP_API_TOKEN'); + expect(el.textContent).toContain('PowerShell'); + }); + + it('copies a setup snippet built from the current origin, with no real token', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + const fixture = render(); + + (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click(); + await fixture.whenStable(); + + expect(writeText).toHaveBeenCalledWith(`pip install mip-client\nexport MIP_URL=${window.location.origin}`); + expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Copied'); + }); + + it('clears the copy status so the next copy is announced again', async () => { + vi.useFakeTimers(); + vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + const page = render().componentInstance; + + await page.copy(page.setupSnippet(), page.copyStatus); + expect(page.copyStatus()).toBe('Copied'); + vi.advanceTimersByTime(2400); + expect(page.copyStatus()).toBe(''); + }); + + it('tells the user to copy by hand when the clipboard refuses', async () => { + vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied')); + const fixture = render(); + + (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click(); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Could not copy'); + }); +}); diff --git a/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.ts b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.ts new file mode 100644 index 000000000..996d04569 --- /dev/null +++ b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.ts @@ -0,0 +1,43 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { HttpClient } from '@angular/common/http'; +import { RouterModule } from '@angular/router'; +import { catchError, map, of } from 'rxjs'; +import { AuthConfig } from '../cli-token'; +import { copyToClipboard } from '../copy-to-clipboard'; + +type Platform = 'linux' | 'windows'; + +/** How to set up the mip client on the user's own computer, per operating system. */ +@Component({ + selector: 'app-remote-access-guide', + imports: [RouterModule], + templateUrl: './remote-access-guide.component.html', + styleUrls: ['../account-page.component.css', './remote-access-guide.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RemoteAccessGuideComponent { + // undefined while loading; null when the platform has no Keycloak client for the mip client. + readonly authConfig = toSignal( + inject(HttpClient).get('/services/auth-config').pipe( + map((config): AuthConfig | null => config), + catchError(() => of(null)), + ), + ); + readonly platforms: { id: Platform; label: string; icon: string }[] = [ + { id: 'linux', label: 'Linux / macOS', icon: 'fab fa-linux' }, + { id: 'windows', label: 'Windows', icon: 'fab fa-windows' }, + ]; + readonly platform = signal(navigator.userAgent.includes('Windows') ? 'windows' : 'linux'); + readonly shellName = computed(() => (this.platform() === 'windows' ? 'PowerShell' : 'Terminal')); + readonly setupSnippet = computed(() => [ + 'pip install mip-client', + this.platform() === 'windows' + ? `$env:MIP_URL = "${window.location.origin}"` + : `export MIP_URL=${window.location.origin}`, + ].join('\n')); + readonly tokenVariable = computed(() => + this.platform() === 'windows' ? '$env:MIP_API_TOKEN = "…"' : 'export MIP_API_TOKEN=…'); + readonly copyStatus = signal(''); + readonly copy = copyToClipboard; +} From 587464be677403fd23c522fd7cca6f182596cc27 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 13 Nov 2026 15:10:16 +0200 Subject: [PATCH 2/3] test(e2e): follow the folded transformation cards and the dropped fallback rule The nominal-column flow filled an 'Otherwise' fallback field that the studio no longer has and expected the folded nominal card's rule to stay visible after K-means was added. It now adds the column without a fallback, expects no default_enumeration in the request, and reopens the nominal card before reading its rule. --- e2e/experiment-studio.flows.spec.ts | 6 ++++-- e2e/studio.ts | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/e2e/experiment-studio.flows.spec.ts b/e2e/experiment-studio.flows.spec.ts index e3f698e19..56c54f741 100644 --- a/e2e/experiment-studio.flows.spec.ts +++ b/e2e/experiment-studio.flows.spec.ts @@ -363,11 +363,14 @@ test.describe('filters and transformations', () => { test('nominal column and k-means cluster column together', async ({ page, api }) => { await openAgeAndSex(page); - await addNominalColumn(page, 'sex_group', 'female', 'other'); + await addNominalColumn(page, 'sex_group', 'female'); await addKMeansColumn(page, { stepAdded: true }); await expect(page.getByText('Nominal column · sex_group')).toBeVisible(); await expect(page.locator('app-station-card', { hasText: 'Nominal column · sex_group' })).toContainText('Applied'); await expect(page.getByText('Column kmeans_cluster')).toBeVisible(); + // Choosing the K-means card folds the nominal one; opening it again shows its rule. + await expect(page.getByText('Sex is Female')).toBeHidden(); + await page.getByRole('button', { name: /^Nominal column · sex_group/ }).click(); await expect(page.getByText('Sex is Female')).toBeVisible(); const steps = lastRequest(api, 'POST', PREVIEW, 'describe').analysis.preprocessing; @@ -377,7 +380,6 @@ test.describe('filters and transformations', () => { new_column_names: ['sex_group'], strategy: 'filter_rules', rules: { female: { condition: 'AND', rules: [expect.objectContaining({ field: 'sex', operator: 'equal', value: 'F' })], valid: true } }, - default_enumeration: 'other', }, }); expect(steps).toContainEqual({ diff --git a/e2e/studio.ts b/e2e/studio.ts index 146d6b767..fa3e26f88 100644 --- a/e2e/studio.ts +++ b/e2e/studio.ts @@ -205,7 +205,7 @@ export async function applyFilters(page: Page, { female = false } = {}) { await page.locator('.filter-workflow-section').getByRole('button', { name: 'Apply filters' }).click(); } -export async function addNominalColumn(page: Page, code: string, category: string, fallback: string) { +export async function addNominalColumn(page: Page, code: string, category: string) { await page.getByTitle('Add and open a derived column').click(); await page.getByRole('button', { name: /Nominal column from rules/ }).click(); await page.getByPlaceholder('e.g. mrs_good_outcome').fill(code); @@ -216,7 +216,6 @@ export async function addNominalColumn(page: Page, code: string, category: strin await fields.getByPlaceholder('Type to find a variable').fill('Sex'); await fields.locator('select.filter-field-input').last().selectOption({ label: 'Female' }); await editor.getByRole('button', { name: 'Done' }).click(); - await page.getByPlaceholder('unknown').fill(fallback); await expect(page.getByText('Sex is Female')).toBeVisible(); } From f9e4a22008aedf34142b8622ae652476f5a4f47e Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 13 Nov 2026 15:10:16 +0200 Subject: [PATCH 3/3] test(notebook): stop the Hub-login redirect from depending on shared sessionStorage The component spec throttled the redirect through sessionStorage, which every spec file in the Vitest browser page shares. hub-session.spec clears it while notebook.component.spec is waiting, so the redirect could fire and pull the iframe away (Cannot connect to the iframe). The specs now mark the component's page as gone instead, so ensureHubSession answers failed and never navigates. --- src/app/pages/notebook/notebook.component.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/pages/notebook/notebook.component.spec.ts b/src/app/pages/notebook/notebook.component.spec.ts index f0eaa3e61..290dfc374 100644 --- a/src/app/pages/notebook/notebook.component.spec.ts +++ b/src/app/pages/notebook/notebook.component.spec.ts @@ -63,11 +63,6 @@ describe('NotebookComponent', () => { beforeEach(async () => { localStorage.clear(); - // The Hub-login redirect navigates via window.location and would pull the Vitest - // browser iframe away from the harness ("Cannot connect to the iframe"), killing - // the whole run when CI timing lets a wait exhaust. Throttle it for every test — - // none of them expects the redirect; they expect ready or a spawn error. - sessionStorage.setItem('mip_notebook_hub_login_redirect_ts', String(Date.now())); (window as Window & { __env?: Record }).__env = { JUPYTER_CONTEXT_PATH: '/notebook', }; @@ -94,6 +89,12 @@ describe('NotebookComponent', () => { fixture = TestBed.createComponent(NotebookComponent); component = fixture.componentInstance; + // The Hub-login redirect navigates via window.location and would pull the Vitest browser + // iframe away from the harness ("Cannot connect to the iframe"), killing the whole run + // when a Hub wait exhausts. No test expects it (they expect ready or a spawn error), so + // tell the component its page is gone: ensureHubSession then answers 'failed' instead. + // A sessionStorage throttle is not enough, spec files share it and clear it mid-run. + (component as unknown as { active: boolean }).active = false; }); afterEach(() => { @@ -245,7 +246,6 @@ describe('NotebookComponent', () => { }); it('shows an error when the Hub session never becomes available', async () => { - sessionStorage.setItem('mip_notebook_hub_login_redirect_ts', String(Date.now())); fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes('/hub/api/user') || url.includes('/hub/home')) {