From ed6e8d55f587490422bddaa48b050501d8c36eaf Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:00:50 +0530 Subject: [PATCH 01/18] fix: clean up unused migration destinations before exit --- src/lib/layout/wizard.svelte | 36 +++-- .../(migration-wizard)/wizard.svelte | 140 +++++++++++++----- 2 files changed, 121 insertions(+), 55 deletions(-) diff --git a/src/lib/layout/wizard.svelte b/src/lib/layout/wizard.svelte index 8a0d696b04..f4f5735d5f 100644 --- a/src/lib/layout/wizard.svelte +++ b/src/lib/layout/wizard.svelte @@ -18,6 +18,7 @@ columnSize?: 's' | 'm' | 'l'; stickySide?: boolean; onExit?: () => void; + beforeExit?: () => Promise; } | { title?: string; @@ -30,6 +31,7 @@ columnSize?: 's' | 'm' | 'l'; stickySide?: boolean; onExit?: () => void; + beforeExit?: () => Promise; }; export let title: $$Props['title'] = ''; @@ -42,6 +44,23 @@ export let columnSize: $$Props['columnSize'] = 'm'; export let stickySide: $$Props['stickySide'] = false; export let onExit: $$Props['onExit'] = undefined; + export let beforeExit: $$Props['beforeExit'] = undefined; + + let exiting = false; + + async function exit() { + if (exiting) return; + exiting = true; + try { + if (beforeExit && !(await beforeExit())) return; + trackEvent('wizard_exit', { from: 'prompt' }); + wizard.hide(); + onExit?.(); + onExit = null; + } finally { + exiting = false; + } + } function handleKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { @@ -95,22 +114,7 @@ {#if showExitModal} - { - trackEvent('wizard_exit', { - from: 'prompt' - }); - - wizard.hide(); - if (onExit) { - onExit(); - - // clear exit - onExit = null; - } - }}> + Are you sure you want to exit from this process? All data will be deleted. This action is irreversible. diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte b/src/routes/(console)/(migration-wizard)/wizard.svelte index 8cb03f25b8..4348071e4f 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte @@ -38,6 +38,7 @@ const onExit = () => { formData.reset(); + selectedProject.set(null); requestedMigration.set(null); }; @@ -50,6 +51,9 @@ let creatingProject = false; let errorInResources = false; let migrationStarted = false; + let migrationAttempted = false; + let cancelling = false; + let creation: Promise | null = null; let projectSdkInstance: ReturnType | null = null; let projects = [] as Models.ProjectList['projects']; @@ -57,7 +61,51 @@ let newProjName = ''; let projectType: 'existing' | 'new' = 'existing'; + let targetProject: Models.Project | null = null; let newlyCreatedProject: Models.Project | null = null; + let createdFor: { organization: string; name: string; region: string } | null = null; + + async function discardCreatedProject() { + if (!newlyCreatedProject || migrationAttempted) return; + try { + await sdk + .forProject(newlyCreatedProject.region, newlyCreatedProject.$id) + .project.delete(); + } catch (error) { + if (error.code !== 404) throw error; + } + newlyCreatedProject = null; + createdFor = null; + await invalidate(Dependencies.PROJECTS); + } + + async function beforeExit() { + if (migrationStarted) return false; + cancelling = true; + try { + await creation; + await discardCreatedProject(); + return true; + } catch (error) { + addNotification({ type: 'error', message: error.message }); + return false; + } finally { + cancelling = false; + } + } + + async function next() { + if (creatingProject || cancelling) return; + creatingProject = true; + creation = prepareProject(); + const project = await creation; + creation = null; + creatingProject = false; + if (!project || cancelling) return; + targetProject = project; + projectSdkInstance = sdk.forProject(project.region, project.$id); + showResources = true; + } async function getProjects(orgId: string | null) { if (!orgId) { @@ -76,7 +124,7 @@ if (projectType === 'existing') { const first = projects[0]; $selectedProject = first.$id; - projectSdkInstance = sdk.forProject(first.region, first.region); + projectSdkInstance = sdk.forProject(first.region, first.$id); } } } @@ -85,33 +133,48 @@ return isExisting ? currentSelectedProject.name : newProjName || 'New project'; } - async function createNewProject() { - creatingProject = true; - + async function prepareProject(): Promise { + const organization = selectedOrg; + const name = newProjName.trim(); + const region = $selectedRegion; + const existing = isExisting ? currentSelectedProject : null; try { - return await sdk.forConsole.organization(selectedOrg).createProject({ + if (existing) { + await discardCreatedProject(); + return existing; + } + if ( + newlyCreatedProject && + createdFor?.organization === organization && + createdFor.name === name && + createdFor.region === region + ) { + return newlyCreatedProject; + } + await discardCreatedProject(); + newlyCreatedProject = await sdk.forConsole.organization(organization).createProject({ projectId: ID.unique(), - name: newProjName, - region: $selectedRegion + name, + region }); + createdFor = { organization, name, region }; + migrationAttempted = false; + return newlyCreatedProject; } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); - + addNotification({ type: 'error', message: error.message }); return null; - } finally { - creatingProject = false; } } const onFinish = async () => { - if ($provider.provider !== 'appwrite') return; + if ($provider.provider !== 'appwrite' || migrationStarted || cancelling) return; migrationStarted = true; const resources = migrationFormToResources($formData, $provider.provider); + // A failed response can still mean the server started importing data. + // From this point, the destination must be kept even if the user exits. + migrationAttempted = true; try { await projectSdkInstance.migrations.createAppwriteMigration({ resources: resources as AppwriteMigrationResource[], @@ -126,7 +189,6 @@ }); onExit(); await invalidate(Dependencies.PROJECTS); - const targetProject = newlyCreatedProject ?? currentSelectedProject; await goto( `${base}/project-${targetProject.region ?? 'default'}-${targetProject.$id}/settings/migrations` ); @@ -146,16 +208,18 @@ $: isExisting = projectType === 'existing'; - $: if (isExisting && $selectedProject) { + $: if (isExisting && currentSelectedProject) { projectSdkInstance = sdk.forProject( currentSelectedProject.region, currentSelectedProject.$id ); } - $: disableNextButton = isExisting - ? !$selectedProject - : newProjName.trim() === '' || creatingProject; + $: disableNextButton = + creatingProject || + cancelling || + loadingProjects || + (isExisting ? !currentSelectedProject : newProjName.trim() === ''); $: isFinalsButtonEnabled = showResources && @@ -166,7 +230,15 @@ ); - + + + {#if newlyCreatedProject && !migrationAttempted} + Exit this migration and delete the new project "{newlyCreatedProject.name}"? + {:else} + Exit this migration setup? Existing projects and any migration already submitted will be + kept. + {/if} + {#if !showResources} @@ -181,7 +253,7 @@ label: project.name, value: project.$id }))} - disabled={loadingProjects} /> + disabled={loadingProjects || creatingProject || cancelling} /> {/if} @@ -253,21 +325,7 @@ { - if (isExisting) { - showResources = true; - } else { - const project = await createNewProject(); - if (project !== null) { - newlyCreatedProject = project; - projectSdkInstance = sdk.forProject( - project.region, - project.$id - ); - showResources = true; - } - } - }}> + on:click={next}> {#if creatingProject} {/if} @@ -297,6 +355,7 @@ (showResources = !showResources)}> Update @@ -376,14 +435,17 @@ - (showExitModal = true)}> + (showExitModal = true)}> Cancel + disabled={!isFinalsButtonEnabled || migrationStarted || cancelling}> {#if migrationStarted} {/if} From a2f05e3b3eedceabd30bf08ab4e81e574b9fe570 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:00:57 +0530 Subject: [PATCH 02/18] test: preserve existing migration destinations on cancellation --- .../resource-form.fixture.svelte | 1 + .../(migration-wizard)/wizard.svelte.test.ts | 169 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/routes/(console)/(migration-wizard)/resource-form.fixture.svelte create mode 100644 src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts diff --git a/src/routes/(console)/(migration-wizard)/resource-form.fixture.svelte b/src/routes/(console)/(migration-wizard)/resource-form.fixture.svelte new file mode 100644 index 0000000000..75add7be20 --- /dev/null +++ b/src/routes/(console)/(migration-wizard)/resource-form.fixture.svelte @@ -0,0 +1 @@ +

Choose migration resources

diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts new file mode 100644 index 0000000000..9ee041be5e --- /dev/null +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { sdk } from '$lib/stores/sdk'; +import { wizard } from '$lib/stores/wizard'; +import { formData, provider, selectedProject, selectedRegion } from '.'; +import { goto, invalidate } from '$app/navigation'; +import { addNotification } from '$lib/stores/notifications'; +import { Dependencies } from '$lib/constants'; +import { get } from 'svelte/store'; +import { Region } from '@appwrite.io/console'; +import MigrationWizard from './wizard.svelte'; + +const api = vi.hoisted(() => ({ + listProjects: vi.fn(), + createProject: vi.fn(), + deleteProject: vi.fn(), + createMigration: vi.fn() +})); + +vi.mock('$lib/commandCenter', async () => { + const { readable } = await import('svelte/store'); + return { disableCommands: readable(vi.fn()) }; +}); +vi.mock('$lib/actions/analytics', () => ({ trackEvent: vi.fn() })); +vi.mock('$lib/stores/sdk', () => ({ + sdk: { + forConsole: { organization: vi.fn(() => api) }, + forProject: vi.fn(() => ({ + project: { delete: api.deleteProject }, + migrations: { createAppwriteMigration: api.createMigration } + })) + } +})); +vi.mock('$app/state', () => ({ + page: { data: { organizations: { teams: [{ $id: 'team', name: 'My team' }] } } } +})); +vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidate: vi.fn() })); +vi.mock('$lib/stores/notifications', () => ({ addNotification: vi.fn() })); +vi.mock('$lib/stores/organization', async () => { + const { writable } = await import('svelte/store'); + return { regions: writable({ regions: [] }) }; +}); +vi.mock('$routes/store', async () => { + const { writable } = await import('svelte/store'); + return { requestedMigration: writable(null) }; +}); +vi.mock('$lib/layout', async () => ({ + Wizard: (await import('$lib/layout/wizard.svelte')).default +})); +vi.mock('$lib/components', async () => ({ + EyebrowHeading: (await import('$lib/components/eyebrowHeading.svelte')).default +})); +vi.mock('$lib/elements/forms', async () => ({ + InputText: (await import('$lib/elements/forms/inputText.svelte')).default, + InputSelect: (await import('$lib/elements/forms/inputSelect.svelte')).default, + Button: (await import('$lib/elements/forms/button.svelte')).default +})); +vi.mock('./resource-form.svelte', async () => ({ + default: (await import('./resource-form.fixture.svelte')).default +})); + +const created = { $id: 'destination', name: 'Imported project', region: 'fra' }; + +async function next() { + await fireEvent.input(await screen.findByLabelText('Project name'), { + target: { value: 'Imported project' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); +} + +async function cancel() { + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { + name: 'Exit' + }) + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function selectResources() { + await act(() => formData.update((data) => ({ ...data, users: { root: true, teams: false } }))); +} + +describe('migration destination cancellation', () => { + beforeAll(() => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + disconnect() {} + unobserve() {} + } + ); + Object.defineProperties(HTMLDialogElement.prototype, { + showModal: { + configurable: true, + value() { + this.open = true; + } + }, + close: { + configurable: true, + value() { + this.open = false; + } + } + }); + }); + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(sdk.forConsole.organization).mockReturnValue(api as never); + vi.mocked(sdk.forProject).mockReturnValue({ + project: { delete: api.deleteProject }, + migrations: { createAppwriteMigration: api.createMigration } + } as never); + api.listProjects.mockResolvedValue({ projects: [] }); + api.createProject.mockResolvedValue(created); + api.deleteProject.mockResolvedValue({}); + api.createMigration.mockResolvedValue({}); + selectedProject.set(null); + selectedRegion.set(Region.Fra); + formData.reset(); + provider.set({ + provider: 'appwrite', + endpoint: 'https://source.example/v1', + projectID: 'source', + apiKey: 'test-key' + }); + vi.spyOn(wizard, 'hide'); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('deletes the destination created by this wizard when cancellation is confirmed', async () => { + render(MigrationWizard); + await next(); + await cancel(); + + await waitFor(() => expect(api.deleteProject).toHaveBeenCalledOnce()); + expect(sdk.forProject).toHaveBeenLastCalledWith('fra', 'destination'); + expect(wizard.hide).toHaveBeenCalledOnce(); + }); + + it('leaves a selected existing project untouched', async () => { + api.listProjects.mockResolvedValue({ projects: [{ ...created, $id: 'existing' }] }); + render(MigrationWizard); + await waitFor(() => expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled()); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.createProject).not.toHaveBeenCalled(); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + +}); From 0f4fa4ad9cd5eaf762e3ff288b784a52bb7fd57b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:01:03 +0530 Subject: [PATCH 03/18] test: preserve migration destination when exit is dismissed --- .../(migration-wizard)/wizard.svelte.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 9ee041be5e..a6a3a53f26 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -166,4 +166,27 @@ describe('migration destination cancellation', () => { expect(api.deleteProject).not.toHaveBeenCalled(); }); + it('keeps the new project when exit confirmation is dismissed', async () => { + render(MigrationWizard); + await next(); + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' }) + ); + expect(api.deleteProject).not.toHaveBeenCalled(); + expect(wizard.hide).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Update' })).toBeVisible(); + await cancel(); + await waitFor(() => expect(api.deleteProject).toHaveBeenCalledOnce()); + }); + + it('exits before project creation without deleting anything', async () => { + render(MigrationWizard); + await screen.findByLabelText('Project name'); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.createProject).not.toHaveBeenCalled(); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + }); From 3ac134139f6f3e20399f8fa8075185fd5045c71e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:01:09 +0530 Subject: [PATCH 04/18] test: reuse the destination when reviewing migration settings --- .../(console)/(migration-wizard)/wizard.svelte.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index a6a3a53f26..289e1b63d1 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -189,4 +189,14 @@ describe('migration destination cancellation', () => { expect(api.deleteProject).not.toHaveBeenCalled(); }); + it('reuses the same project after Update and Next', async () => { + render(MigrationWizard); + await next(); + await fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); + expect(api.createProject).toHaveBeenCalledOnce(); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + }); From b6d10ccae64d42572796d67cbafc06cd29ad6911 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:01:15 +0530 Subject: [PATCH 05/18] test: release the old destination before creating a replacement --- .../(migration-wizard)/wizard.svelte.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 289e1b63d1..1e01746010 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -199,4 +199,29 @@ describe('migration destination cancellation', () => { expect(api.deleteProject).not.toHaveBeenCalled(); }); + it('deletes the previous unused project before creating one with changed settings', async () => { + render(MigrationWizard); + await next(); + await fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await fireEvent.input(screen.getByLabelText('Project name'), { + target: { value: 'Replacement' } + }); + api.createProject.mockResolvedValue({ + ...created, + $id: 'replacement', + name: 'Replacement' + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect(api.createProject).toHaveBeenCalledTimes(2); + expect(api.deleteProject.mock.invocationCallOrder[0]).toBeLessThan( + api.createProject.mock.invocationCallOrder[1] + ); + expect(api.createProject).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'Replacement' }) + ); + expect(invalidate).toHaveBeenCalledWith(Dependencies.PROJECTS); + }); + }); From c35280d563005b4942142f1e0cd4b32c22f5dc75 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:01:23 +0530 Subject: [PATCH 06/18] test: delete migration projects through their returned region --- .../(migration-wizard)/wizard.svelte.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 1e01746010..0b11ff7363 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -224,4 +224,17 @@ describe('migration destination cancellation', () => { expect(invalidate).toHaveBeenCalledWith(Dependencies.PROJECTS); }); + it('uses the created project region and ID even if the selected region changes', async () => { + api.createProject.mockResolvedValue({ ...created, region: 'syd' }); + render(MigrationWizard); + await next(); + await act(() => selectedRegion.set(Region.Fra)); + await cancel(); + await waitFor(() => expect(api.deleteProject).toHaveBeenCalledOnce()); + expect(sdk.forProject).toHaveBeenLastCalledWith('syd', 'destination'); + expect(api.createProject).toHaveBeenCalledWith( + expect.objectContaining({ region: Region.Fra }) + ); + }); + }); From 6297c1ff32cb8985bfacbcc5d6117604e92ca598 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:01:32 +0530 Subject: [PATCH 07/18] test: recover from failed migration destination creation --- .../(migration-wizard)/wizard.svelte.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 0b11ff7363..283d2f39ec 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -237,4 +237,23 @@ describe('migration destination cancellation', () => { ); }); + it('reports creation failure and exits without issuing a delete', async () => { + api.createProject.mockRejectedValue(new Error('Creation failed')); + render(MigrationWizard); + await fireEvent.input(await screen.findByLabelText('Project name'), { + target: { value: 'Imported project' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Creation failed' + }) + ); + expect(screen.queryByRole('button', { name: 'Update' })).not.toBeInTheDocument(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + }); From a005ed3806ffafd034f9861b002d364447dabd97 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:01:40 +0530 Subject: [PATCH 08/18] test: keep migration setup open when project cleanup fails --- .../(migration-wizard)/wizard.svelte.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 283d2f39ec..436cdeb400 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -256,4 +256,41 @@ describe('migration destination cancellation', () => { expect(api.deleteProject).not.toHaveBeenCalled(); }); + it('keeps ownership after a failed deletion so cancellation can retry', async () => { + api.deleteProject.mockRejectedValueOnce(new Error('Deletion failed')); + render(MigrationWizard); + await next(); + await cancel(); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Deletion failed' + }) + ); + expect(wizard.hide).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Update' })).toBeVisible(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledTimes(2); + }); + + it('does not create a replacement while cleanup of the previous destination fails', async () => { + render(MigrationWizard); + await next(); + api.deleteProject.mockRejectedValue(new Error('Deletion failed')); + await fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await fireEvent.input(screen.getByLabelText('Project name'), { + target: { value: 'Replacement' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Deletion failed' + }) + ); + expect(api.createProject).toHaveBeenCalledOnce(); + expect(screen.getByLabelText('Project name')).toHaveValue('Replacement'); + }); + }); From 0ca760b5506ea0d18c53157ad0ec3994d291a3d3 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:01:50 +0530 Subject: [PATCH 09/18] test: treat an already removed migration destination as cancelled --- .../(console)/(migration-wizard)/wizard.svelte.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 436cdeb400..5bce9a59d5 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -293,4 +293,14 @@ describe('migration destination cancellation', () => { expect(screen.getByLabelText('Project name')).toHaveValue('Replacement'); }); + it('finishes cancellation when the new project was already deleted', async () => { + api.deleteProject.mockRejectedValue({ code: 404, message: 'Project not found' }); + render(MigrationWizard); + await next(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(invalidate).toHaveBeenCalledWith(Dependencies.PROJECTS); + expect(addNotification).not.toHaveBeenCalled(); + }); + }); From 0137b478c5b2c5382221410e8530f47e5d841c00 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:01:58 +0530 Subject: [PATCH 10/18] test: wait for destination cleanup before closing migration setup --- .../(migration-wizard)/wizard.svelte.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 5bce9a59d5..351e7986a2 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -303,4 +303,17 @@ describe('migration destination cancellation', () => { expect(addNotification).not.toHaveBeenCalled(); }); + it('waits for deletion to finish before hiding the wizard', async () => { + const deletion = deferred(); + api.deleteProject.mockReturnValue(deletion.promise); + render(MigrationWizard); + await next(); + await cancel(); + expect(wizard.hide).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + await act(() => deletion.resolve({})); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledOnce(); + }); + }); From ce2396df119e291290a1a01ab82b371167d485f2 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:02:06 +0530 Subject: [PATCH 11/18] test: clean up projects created while migration exit is pending --- .../(migration-wizard)/wizard.svelte.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 351e7986a2..580f31d83f 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -316,4 +316,37 @@ describe('migration destination cancellation', () => { expect(api.deleteProject).toHaveBeenCalledOnce(); }); + it('waits for an in-flight creation and deletes its result after confirmed exit', async () => { + const creation = deferred(); + api.createProject.mockReturnValue(creation.promise); + render(MigrationWizard); + await fireEvent.input(await screen.findByLabelText('Project name'), { + target: { value: 'Imported project' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => expect(api.createProject).toHaveBeenCalledOnce()); + await cancel(); + expect(api.deleteProject).not.toHaveBeenCalled(); + expect(wizard.hide).not.toHaveBeenCalled(); + await act(() => creation.resolve(created)); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect(screen.queryByRole('button', { name: 'Update' })).not.toBeInTheDocument(); + }); + + it('does not submit a second project creation while Next is pending', async () => { + const creation = deferred(); + api.createProject.mockReturnValue(creation.promise); + render(MigrationWizard); + await fireEvent.input(await screen.findByLabelText('Project name'), { + target: { value: 'Imported project' } + }); + const button = screen.getByRole('button', { name: 'Next' }); + await fireEvent.click(button); + await fireEvent.click(button); + expect(api.createProject).toHaveBeenCalledOnce(); + await act(() => creation.resolve(created)); + await screen.findByRole('button', { name: 'Update' }); + }); + }); From 2700a17c8c9873d3cafc8b27db01f5e82fcf841d Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:02:13 +0530 Subject: [PATCH 12/18] test: retain destinations after an ambiguous migration response --- .../(migration-wizard)/wizard.svelte.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 580f31d83f..1ae704ae98 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -349,4 +349,21 @@ describe('migration destination cancellation', () => { await screen.findByRole('button', { name: 'Update' }); }); + it('keeps the project after a migration request fails because importing may have started', async () => { + api.createMigration.mockRejectedValue(new Error('Connection lost')); + render(MigrationWizard); + await next(); + await selectResources(); + await fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Connection lost' + }) + ); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + }); From d63306c309c83069cf69d002ac2b50a77235e8a2 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:02:22 +0530 Subject: [PATCH 13/18] test: finish migrations with the prepared destination and reset setup --- .../(migration-wizard)/wizard.svelte.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 1ae704ae98..29004698ff 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -366,4 +366,23 @@ describe('migration destination cancellation', () => { expect(api.deleteProject).not.toHaveBeenCalled(); }); + it('keeps a successfully submitted destination and navigates to its migrations', async () => { + render(MigrationWizard); + await next(); + await selectResources(); + await fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(goto).toHaveBeenCalledWith( + expect.stringContaining('/project-fra-destination/settings/migrations') + ) + ); + expect(api.createMigration).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'source', endpoint: 'https://source.example/v1' }) + ); + expect(api.deleteProject).not.toHaveBeenCalled(); + expect(invalidate).toHaveBeenCalledWith(Dependencies.PROJECTS); + expect(get(formData).users.root).toBe(false); + expect(get(selectedProject)).toBeNull(); + }); + }); From 58d1154b726b56f13375838dc66b611e48ae4d06 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:02:30 +0530 Subject: [PATCH 14/18] test: preserve destination data during an active import request --- .../(migration-wizard)/wizard.svelte.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 29004698ff..672d065245 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -385,4 +385,23 @@ describe('migration destination cancellation', () => { expect(get(selectedProject)).toBeNull(); }); + it('prevents exit while the migration request is still in flight', async () => { + const migration = deferred(); + api.createMigration.mockReturnValue(migration.promise); + render(MigrationWizard); + await next(); + await selectResources(); + await fireEvent.click(screen.getByRole('button', { name: 'Create' })); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Update' })).toBeDisabled(); + await fireEvent.keyDown(window, { key: 'Escape' }); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + expect(wizard.hide).not.toHaveBeenCalled(); + expect(api.deleteProject).not.toHaveBeenCalled(); + await act(() => migration.resolve({})); + await waitFor(() => expect(goto).toHaveBeenCalled()); + }); + }); From 52f7ed48536e913eea418ce49e30414d1f2ad6d7 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:02:38 +0530 Subject: [PATCH 15/18] test: run migration cleanup for confirmed keyboard exits --- .../(console)/(migration-wizard)/wizard.svelte.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 672d065245..92c389fd6e 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -404,4 +404,14 @@ describe('migration destination cancellation', () => { await waitFor(() => expect(goto).toHaveBeenCalled()); }); + it('deletes the new destination when Escape exit is confirmed', async () => { + render(MigrationWizard); + await next(); + await fireEvent.keyDown(window, { key: 'Escape' }); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledOnce(); + }); }); From c5d5c2e0c8e732e57615dadaa263f15f2faecaba Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:09:02 +0530 Subject: [PATCH 16/18] fix: keep migration destination display tied to the prepared project --- .../(migration-wizard)/wizard.svelte | 6 +-- .../(migration-wizard)/wizard.svelte.test.ts | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte b/src/routes/(console)/(migration-wizard)/wizard.svelte index 4348071e4f..a788a1c92a 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte @@ -129,10 +129,6 @@ } } - function getProjectName(): string { - return isExisting ? currentSelectedProject.name : newProjName || 'New project'; - } - async function prepareProject(): Promise { const organization = selectedOrg; const name = newProjName.trim(); @@ -348,7 +344,7 @@ - {capitalize(getProjectName())} + {capitalize(targetProject?.name ?? 'New project')} diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 92c389fd6e..41cbba7559 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -414,4 +414,41 @@ describe('migration destination cancellation', () => { await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); expect(api.deleteProject).toHaveBeenCalledOnce(); }); + + it('finishes using an existing project after releasing the wizard-created destination', async () => { + api.listProjects.mockResolvedValue({ + projects: [{ ...created, $id: 'existing', name: 'Existing destination', region: 'syd' }] + }); + render(MigrationWizard); + await fireEvent.click(await screen.findByRole('radio', { name: /Create new project/ })); + await next(); + await fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await fireEvent.click(screen.getByRole('radio', { name: /Existing project/ })); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); + expect(api.deleteProject).toHaveBeenCalledOnce(); + await selectResources(); + await fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(goto).toHaveBeenCalledWith( + expect.stringContaining('/project-syd-existing/settings/migrations') + ) + ); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect(get(selectedProject)).toBeNull(); + }); + + it('displays the prepared project name when creation returns after the input changes', async () => { + const creation = deferred(); + api.createProject.mockReturnValue(creation.promise); + render(MigrationWizard); + const input = await screen.findByLabelText('Project name'); + await fireEvent.input(input, { target: { value: 'Imported project' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await fireEvent.input(input, { target: { value: 'Changed while creating' } }); + await act(() => creation.resolve(created)); + await screen.findByRole('button', { name: 'Update' }); + expect(screen.getByText('Imported project')).toBeVisible(); + expect(screen.queryByText('Changed while creating')).not.toBeInTheDocument(); + }); }); From 66569f45546cd884fb3a0dae4dfb317d25a6f7a4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:18:26 +0530 Subject: [PATCH 17/18] fix: confirm migration cleanup before browser navigation --- src/lib/layout/shell.svelte | 16 +++- src/lib/layout/wizard.svelte | 24 ++++- src/lib/stores/wizard.test.ts | 51 +++++++++++ src/lib/stores/wizard.ts | 16 ++++ .../(migration-wizard)/wizard.svelte | 2 + .../(migration-wizard)/wizard.svelte.test.ts | 88 +++++++++++++++++++ 6 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 src/lib/stores/wizard.test.ts diff --git a/src/lib/layout/shell.svelte b/src/lib/layout/shell.svelte index d0215144a5..8a41f53b16 100644 --- a/src/lib/layout/shell.svelte +++ b/src/lib/layout/shell.svelte @@ -111,11 +111,19 @@ beforeNavigate((navigation) => { if (navigation.willUnload) return; if (!($wizard.show || $wizard.cover)) return; - if (navigation.type === 'popstate') { + if ($wizard.exitHandler) { navigation.cancel(); - } - if (navigation.type !== 'leave') { - wizard.hide(); + $wizard.exitHandler( + navigation.type === 'popstate' ? null : (navigation.to?.url.href ?? null) + ); + return; + } else { + if (navigation.type === 'popstate') { + navigation.cancel(); + } + if (navigation.type !== 'leave') { + wizard.hide(); + } } if (!isInDatabasesRoute(navigation.from.route)) { diff --git a/src/lib/layout/wizard.svelte b/src/lib/layout/wizard.svelte index f4f5735d5f..f491a300cf 100644 --- a/src/lib/layout/wizard.svelte +++ b/src/lib/layout/wizard.svelte @@ -47,16 +47,35 @@ export let beforeExit: $$Props['beforeExit'] = undefined; let exiting = false; + let pendingHref: string | null = null; + + $: if (!showExitModal && !exiting) pendingHref = null; + + function requestExit(href: string | null) { + if (exiting) return; + pendingHref = href; + if (confirmExit) { + showExitModal = true; + } else { + void exit(); + } + } async function exit() { if (exiting) return; exiting = true; + const destination = pendingHref; try { if (beforeExit && !(await beforeExit())) return; trackEvent('wizard_exit', { from: 'prompt' }); wizard.hide(); onExit?.(); onExit = null; + if (destination) { + // Navigation URLs already include the application base path. + // eslint-disable-next-line svelte/no-navigation-without-resolve + await goto(destination); + } } finally { exiting = false; } @@ -79,7 +98,10 @@ const goBack = () => goto(href); - onMount(() => ($isNewWizardStatusOpen = true)); + onMount(() => { + $isNewWizardStatusOpen = true; + if (beforeExit) return wizard.setExitHandler(requestExit); + }); onDestroy(() => ($isNewWizardStatusOpen = false)); diff --git a/src/lib/stores/wizard.test.ts b/src/lib/stores/wizard.test.ts new file mode 100644 index 0000000000..7f5a4c1a49 --- /dev/null +++ b/src/lib/stores/wizard.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { get } from 'svelte/store'; +import { wizard } from './wizard'; + +vi.mock('$lib/actions/analytics', () => ({ trackEvent: vi.fn() })); + +describe('wizard exit handler ownership', () => { + beforeEach(() => wizard.hide()); + + it('unregisters the handler owned by the closing component', () => { + const handler = vi.fn(); + const unregister = wizard.setExitHandler(handler); + + expect(get(wizard).exitHandler).toBe(handler); + unregister(); + + expect(get(wizard).exitHandler).toBeNull(); + }); + + it('does not unregister a replacement component handler', () => { + const unregister = wizard.setExitHandler(vi.fn()); + const replacement = vi.fn(); + wizard.setExitHandler(replacement); + + unregister(); + + expect(get(wizard).exitHandler).toBe(replacement); + }); + + it('clears the previous handler when another wizard starts', () => { + const unregister = wizard.setExitHandler(vi.fn()); + + wizard.start(() => ({})); + expect(get(wizard).exitHandler).toBeNull(); + + const replacement = vi.fn(); + wizard.setExitHandler(replacement); + unregister(); + expect(get(wizard).exitHandler).toBe(replacement); + }); + + it('clears navigation interception when the wizard is hidden', () => { + wizard.start(() => ({})); + wizard.setExitHandler(vi.fn()); + + wizard.hide(); + + expect(get(wizard).show).toBe(false); + expect(get(wizard).exitHandler).toBeNull(); + }); +}); diff --git a/src/lib/stores/wizard.ts b/src/lib/stores/wizard.ts index e00bd2787a..b74048d69a 100644 --- a/src/lib/stores/wizard.ts +++ b/src/lib/stores/wizard.ts @@ -10,6 +10,7 @@ export type WizardStore = { cover?: Component; interceptor?: () => Promise; finalAction?: () => Promise; + exitHandler?: (href: string | null) => void; nextDisabled: boolean; step: number; interceptorNotificationEnabled: boolean; @@ -27,6 +28,7 @@ function createWizardStore() { nextDisabled: false, step: 1, finalAction: null, + exitHandler: null, props: {} }); @@ -49,6 +51,7 @@ function createWizardStore() { n.cover = null; n.nextDisabled = false; n.finalAction = null; + n.exitHandler = null; n.props = props; trackEvent('wizard_start'); return n; @@ -65,6 +68,18 @@ function createWizardStore() { return n; }); }, + setExitHandler: (handler: WizardStore['exitHandler']) => { + update((n) => { + n.exitHandler = handler; + return n; + }); + return () => { + update((n) => { + if (n.exitHandler === handler) n.exitHandler = null; + return n; + }); + }; + }, hide: () => update((n) => { n.show = false; @@ -76,6 +91,7 @@ function createWizardStore() { n.cover = null; n.nextDisabled = false; n.finalAction = null; + n.exitHandler = null; return n; }), diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte b/src/routes/(console)/(migration-wizard)/wizard.svelte index a788a1c92a..9b1dded0a3 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state'; import { Wizard } from '$lib/layout'; import { sdk } from '$lib/stores/sdk'; + import { wizard } from '$lib/stores/wizard'; import { capitalize } from '$lib/helpers/string'; import ResourceForm from './resource-form.svelte'; import { requestedMigration } from '$routes/store'; @@ -184,6 +185,7 @@ message: 'Migration started' }); onExit(); + wizard.hide(); await invalidate(Dependencies.PROJECTS); await goto( `${base}/project-${targetProject.region ?? 'default'}-${targetProject.$id}/settings/migrations` diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index 41cbba7559..cfc922889d 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -451,4 +451,92 @@ describe('migration destination cancellation', () => { expect(screen.getByText('Imported project')).toBeVisible(); expect(screen.queryByText('Changed while creating')).not.toBeInTheDocument(); }); + + it('cleans up when browser Back requests and confirms exit', async () => { + render(MigrationWizard); + await next(); + await act(() => get(wizard).exitHandler(null)); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect(goto).not.toHaveBeenCalled(); + }); + + it('resumes an internal link only after confirmed cleanup succeeds', async () => { + const deletion = deferred(); + api.deleteProject.mockReturnValue(deletion.promise); + render(MigrationWizard); + await next(); + await act(() => get(wizard).exitHandler('/organization-next')); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + expect(goto).not.toHaveBeenCalled(); + expect(wizard.hide).not.toHaveBeenCalled(); + await act(() => deletion.resolve({})); + await waitFor(() => expect(goto).toHaveBeenCalledWith('/organization-next')); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect(get(wizard).exitHandler).toBeNull(); + }); + + it('forgets a dismissed navigation request before a later Cancel exit', async () => { + render(MigrationWizard); + await next(); + await act(() => get(wizard).exitHandler('/organization-next')); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' }) + ); + expect(api.deleteProject).not.toHaveBeenCalled(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(goto).not.toHaveBeenCalled(); + }); + + it('stays on the current page after navigation cleanup fails', async () => { + api.deleteProject.mockRejectedValueOnce(new Error('Deletion failed')); + render(MigrationWizard); + await next(); + await act(() => get(wizard).exitHandler('/organization-next')); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Deletion failed' + }) + ); + expect(goto).not.toHaveBeenCalled(); + expect(wizard.hide).not.toHaveBeenCalled(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(goto).not.toHaveBeenCalled(); + }); + + it('ignores another navigation request while confirmed cleanup is pending', async () => { + const deletion = deferred(); + api.deleteProject.mockReturnValue(deletion.promise); + render(MigrationWizard); + await next(); + await act(() => get(wizard).exitHandler('/organization-first')); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + await act(() => get(wizard).exitHandler('/organization-second')); + await act(() => deletion.resolve({})); + await waitFor(() => expect(goto).toHaveBeenCalledWith('/organization-first')); + expect(goto).toHaveBeenCalledOnce(); + expect(api.deleteProject).toHaveBeenCalledOnce(); + }); + + it('clears the owned navigation handler when the wizard is unmounted', async () => { + const component = render(MigrationWizard); + await screen.findByLabelText('Project name'); + expect(get(wizard).exitHandler).toEqual(expect.any(Function)); + component.unmount(); + expect(get(wizard).exitHandler).toBeNull(); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); }); From 21e5a2c8d85b5c22a4fa5cbc1b3e6a6ceff754c6 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 15 Sep 2026 13:41:39 +0530 Subject: [PATCH 18/18] test(migrations): exercise exits through the mounted shell navigation hook --- .../(migration-wizard)/wizard.svelte.test.ts | 129 ++++++++++++++---- 1 file changed, 106 insertions(+), 23 deletions(-) diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts index cfc922889d..9c150d8aef 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -3,7 +3,9 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { sdk } from '$lib/stores/sdk'; import { wizard } from '$lib/stores/wizard'; import { formData, provider, selectedProject, selectedRegion } from '.'; -import { goto, invalidate } from '$app/navigation'; +import { beforeNavigate, goto, invalidate } from '$app/navigation'; +import type { BeforeNavigate } from '@sveltejs/kit'; +import Shell from '$lib/layout/shell.svelte'; import { addNotification } from '$lib/stores/notifications'; import { Dependencies } from '$lib/constants'; import { get } from 'svelte/store'; @@ -24,7 +26,10 @@ vi.mock('$lib/commandCenter', async () => { vi.mock('$lib/actions/analytics', () => ({ trackEvent: vi.fn() })); vi.mock('$lib/stores/sdk', () => ({ sdk: { - forConsole: { organization: vi.fn(() => api) }, + forConsole: { + organization: vi.fn(() => api), + avatars: { getInitials: () => new URL('http://localhost/avatar') } + }, forProject: vi.fn(() => ({ project: { delete: api.deleteProject }, migrations: { createAppwriteMigration: api.createMigration } @@ -32,13 +37,38 @@ vi.mock('$lib/stores/sdk', () => ({ } })); vi.mock('$app/state', () => ({ - page: { data: { organizations: { teams: [{ $id: 'team', name: 'My team' }] } } } + page: { + params: {}, + url: new URL('http://localhost/console/organization-current'), + data: { organizations: { teams: [{ $id: 'team', name: 'My team' }] } } + } +})); +vi.mock('$app/navigation', () => ({ + goto: vi.fn(), + invalidate: vi.fn(), + beforeNavigate: vi.fn(), + afterNavigate: vi.fn() +})); +vi.mock('$app/stores', async () => { + const { readable } = await import('svelte/store'); + return { page: readable({ data: {} }) }; +}); +vi.mock('$lib/helpers/sidebar', () => ({ + getSidebarState: () => 'closed', + isInDatabasesRoute: () => false, + updateSidebarState: vi.fn() })); -vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidate: vi.fn() })); +vi.mock('$lib/helpers/project', () => ({ isProjectBlocked: () => false })); +vi.mock('$lib/layout/navigation.svelte', () => ({ default: () => {} })); +vi.mock('$lib/components/impersonation/banner.svelte', () => ({ default: () => {} })); vi.mock('$lib/stores/notifications', () => ({ addNotification: vi.fn() })); vi.mock('$lib/stores/organization', async () => { const { writable } = await import('svelte/store'); - return { regions: writable({ regions: [] }) }; + return { + regions: writable({ regions: [] }), + organization: writable(null), + organizationList: writable({ teams: [] }) + }; }); vi.mock('$routes/store', async () => { const { writable } = await import('svelte/store'); @@ -48,7 +78,9 @@ vi.mock('$lib/layout', async () => ({ Wizard: (await import('$lib/layout/wizard.svelte')).default })); vi.mock('$lib/components', async () => ({ - EyebrowHeading: (await import('$lib/components/eyebrowHeading.svelte')).default + EyebrowHeading: (await import('$lib/components/eyebrowHeading.svelte')).default, + Navbar: () => {}, + Sidebar: () => {} })); vi.mock('$lib/elements/forms', async () => ({ InputText: (await import('$lib/elements/forms/inputText.svelte')).default, @@ -61,6 +93,47 @@ vi.mock('./resource-form.svelte', async () => ({ const created = { $id: 'destination', name: 'Imported project', region: 'fra' }; +function renderWithShell() { + wizard.start(MigrationWizard); + render(Shell, { showHeader: false, showFooter: false }); + const component = render(MigrationWizard); + const before = vi.mocked(beforeNavigate).mock.calls.at(-1)[0]; + return { component, before }; +} + +async function navigate( + before: (navigation: BeforeNavigate) => void, + path: string, + type: 'link' | 'popstate' | 'goto' = 'link' +) { + const cancel = vi.fn(); + const navigation = { + type, + from: { + url: new URL('http://localhost/console/organization-current'), + route: { id: '/(console)/organization-[organization]' }, + params: {}, + scroll: null + }, + to: { + url: new URL(path, 'http://localhost'), + route: { id: '/(console)/organization-[organization]' }, + params: {}, + scroll: null + }, + willUnload: false, + complete: Promise.resolve(), + cancel, + ...(type === 'popstate' + ? { delta: -1, event: new PopStateEvent('popstate') } + : type === 'link' + ? { event: new MouseEvent('click') as PointerEvent } + : {}) + } as BeforeNavigate; + await act(() => before(navigation)); + return cancel; +} + async function next() { await fireEvent.input(await screen.findByLabelText('Project name'), { target: { value: 'Imported project' } @@ -92,6 +165,7 @@ async function selectResources() { describe('migration destination cancellation', () => { beforeAll(() => { + vi.stubGlobal('scrollTo', vi.fn()); vi.stubGlobal( 'IntersectionObserver', class { @@ -118,6 +192,7 @@ describe('migration destination cancellation', () => { beforeEach(() => { vi.resetAllMocks(); + wizard.hide(); vi.mocked(sdk.forConsole.organization).mockReturnValue(api as never); vi.mocked(sdk.forProject).mockReturnValue({ project: { delete: api.deleteProject }, @@ -453,9 +528,10 @@ describe('migration destination cancellation', () => { }); it('cleans up when browser Back requests and confirms exit', async () => { - render(MigrationWizard); + const { before } = renderWithShell(); await next(); - await act(() => get(wizard).exitHandler(null)); + const cancellation = await navigate(before, '/organization-previous', 'popstate'); + expect(cancellation).toHaveBeenCalledOnce(); await fireEvent.click( within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) ); @@ -467,24 +543,28 @@ describe('migration destination cancellation', () => { it('resumes an internal link only after confirmed cleanup succeeds', async () => { const deletion = deferred(); api.deleteProject.mockReturnValue(deletion.promise); - render(MigrationWizard); + const { before } = renderWithShell(); await next(); - await act(() => get(wizard).exitHandler('/organization-next')); + expect(await navigate(before, '/organization-next')).toHaveBeenCalledOnce(); await fireEvent.click( within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) ); expect(goto).not.toHaveBeenCalled(); expect(wizard.hide).not.toHaveBeenCalled(); await act(() => deletion.resolve({})); - await waitFor(() => expect(goto).toHaveBeenCalledWith('/organization-next')); + await waitFor(() => + expect(goto).toHaveBeenCalledWith('http://localhost/organization-next') + ); expect(api.deleteProject).toHaveBeenCalledOnce(); - expect(get(wizard).exitHandler).toBeNull(); + expect( + await navigate(before, 'http://localhost/organization-next', 'goto') + ).not.toHaveBeenCalled(); }); it('forgets a dismissed navigation request before a later Cancel exit', async () => { - render(MigrationWizard); + const { before } = renderWithShell(); await next(); - await act(() => get(wizard).exitHandler('/organization-next')); + expect(await navigate(before, '/organization-next')).toHaveBeenCalledOnce(); await fireEvent.click( within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' }) ); @@ -496,9 +576,9 @@ describe('migration destination cancellation', () => { it('stays on the current page after navigation cleanup fails', async () => { api.deleteProject.mockRejectedValueOnce(new Error('Deletion failed')); - render(MigrationWizard); + const { before } = renderWithShell(); await next(); - await act(() => get(wizard).exitHandler('/organization-next')); + expect(await navigate(before, '/organization-next')).toHaveBeenCalledOnce(); await fireEvent.click( within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) ); @@ -518,25 +598,28 @@ describe('migration destination cancellation', () => { it('ignores another navigation request while confirmed cleanup is pending', async () => { const deletion = deferred(); api.deleteProject.mockReturnValue(deletion.promise); - render(MigrationWizard); + const { before } = renderWithShell(); await next(); - await act(() => get(wizard).exitHandler('/organization-first')); + expect(await navigate(before, '/organization-first')).toHaveBeenCalledOnce(); await fireEvent.click( within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) ); - await act(() => get(wizard).exitHandler('/organization-second')); + expect(await navigate(before, '/organization-second')).toHaveBeenCalledOnce(); await act(() => deletion.resolve({})); - await waitFor(() => expect(goto).toHaveBeenCalledWith('/organization-first')); + await waitFor(() => + expect(goto).toHaveBeenCalledWith('http://localhost/organization-first') + ); expect(goto).toHaveBeenCalledOnce(); expect(api.deleteProject).toHaveBeenCalledOnce(); }); it('clears the owned navigation handler when the wizard is unmounted', async () => { - const component = render(MigrationWizard); + const { component, before } = renderWithShell(); await screen.findByLabelText('Project name'); - expect(get(wizard).exitHandler).toEqual(expect.any(Function)); component.unmount(); - expect(get(wizard).exitHandler).toBeNull(); + + expect(await navigate(before, '/organization-next')).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(api.deleteProject).not.toHaveBeenCalled(); }); });