diff --git a/e2e/experiment-studio.flows.spec.ts b/e2e/experiment-studio.flows.spec.ts index e3f698e1..56c54f74 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 146d6b76..fa3e26f8 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(); } diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 816a2e79..427ac7c8 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 8ae22fcc..4788ad58 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 48317ede..9ebcdfeb 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 8aef1453..a8927145 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 945f78f2..015aac4c 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 00000000..25c8a3c1 --- /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 00000000..b888be5f --- /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 00000000..3b3722e6 --- /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 00000000..9472025a --- /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 00000000..996d0456 --- /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; +} diff --git a/src/app/pages/notebook/notebook.component.spec.ts b/src/app/pages/notebook/notebook.component.spec.ts index f0eaa3e6..290dfc37 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')) {