diff --git a/packages/app/cypress/e2e/top-nav.cy.ts b/packages/app/cypress/e2e/top-nav.cy.ts index 8f574488cf..60cf1beb8e 100644 --- a/packages/app/cypress/e2e/top-nav.cy.ts +++ b/packages/app/cypress/e2e/top-nav.cy.ts @@ -1,5 +1,5 @@ +import type { SinonStub } from 'sinon' import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json' -import type { AuthStateShape } from '@packages/data-context/src/data' const pkg = require('@packages/root') @@ -366,12 +366,14 @@ describe('App Top Nav Workflows', () => { const mockLogInActionsForUser = (user) => { cy.withCtx((ctx, options) => { options.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { - onMessage({ browserOpened: true } as AuthStateShape) + setTimeout(() => { + onMessage({ browserOpened: true }) + }, 500) return new Promise((resolve) => { setTimeout(() => { resolve(options.user) - }, 2000) // timeout ensures full auth browser lifecycle is testable + }, 1000) }) }) }, { user }) @@ -445,12 +447,16 @@ describe('App Top Nav Workflows', () => { }) it('shows correct error when browser cannot launch', () => { - cy.withCtx((ctx) => { - ctx.coreData.authState = { - name: 'AUTH_COULD_NOT_LAUNCH_BROWSER', - message: 'http://127.0.0.1:0000/redirect-to-auth', - browserOpened: false, - } + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_COULD_NOT_LAUNCH_BROWSER', + message: 'http://127.0.0.1:0000/redirect-to-auth', + browserOpened: false, + }) + + throw new Error() + }) }) cy.findByTestId('app-header-bar').within(() => { @@ -458,23 +464,31 @@ describe('App Top Nav Workflows', () => { cy.findByRole('button', { name: 'Log In' }).click() }) - cy.contains('http://127.0.0.1:0000/redirect-to-auth').should('be.visible') - cy.contains(loginText.titleBrowserError).should('be.visible') - cy.contains(loginText.bodyBrowserError).should('be.visible') - cy.contains(loginText.bodyBrowserErrorDetails).should('be.visible') + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('button', { name: 'Log In' }).click() - // in this state, there is no retry UI, we ask the user to visit the auth url on their own - cy.contains('button', loginText.actionTryAgain).should('not.exist') - cy.contains('button', loginText.actionCancel).should('not.exist') + cy.contains('http://127.0.0.1:0000/redirect-to-auth').should('be.visible') + cy.contains(loginText.titleBrowserError).should('be.visible') + cy.contains(loginText.bodyBrowserError).should('be.visible') + cy.contains(loginText.bodyBrowserErrorDetails).should('be.visible') + + // in this state, there is no retry UI, we ask the user to visit the auth url on their own + cy.contains('button', loginText.actionTryAgain).should('not.be.visible') + cy.contains('button', loginText.actionCancel).should('not.be.visible') + }) }) it('shows correct error when error other than browser-launch happens', () => { - cy.withCtx((ctx) => { - ctx.coreData.authState = { - name: 'AUTH_ERROR_DURING_LOGIN', - message: 'An unexpected error occurred', - browserOpened: false, - } + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_ERROR_DURING_LOGIN', + message: 'An unexpected error occurred', + browserOpened: false, + }) + + throw new Error() + }) }) cy.findByTestId('app-header-bar').within(() => { @@ -482,34 +496,49 @@ describe('App Top Nav Workflows', () => { cy.findByRole('button', { name: 'Log In' }).click() }) - cy.contains(loginText.titleFailed).should('be.visible') - cy.contains(loginText.bodyError).should('be.visible') - cy.contains('An unexpected error occurred').should('be.visible') + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('button', { name: 'Log In' }).click() - cy.contains('button', loginText.actionTryAgain).should('be.visible').as('tryAgain') - cy.contains('button', loginText.actionCancel).should('be.visible') + cy.contains(loginText.titleFailed).should('be.visible') + cy.contains(loginText.bodyError).should('be.visible') + cy.contains('An unexpected error occurred').should('be.visible') + + cy.contains('button', loginText.actionTryAgain).should('be.visible').as('tryAgain') + cy.contains('button', loginText.actionCancel).should('be.visible') + }) cy.percySnapshot() cy.withCtx((ctx) => { - ctx.coreData.authState = { - name: 'AUTH_BROWSER_LAUNCHED', - message: '', - browserOpened: true, - } + (ctx._apis.authApi.logIn as SinonStub).callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_BROWSER_LAUNCHED', + message: '', + browserOpened: true, + }) + + return Promise.resolve() + }) }) cy.get('@tryAgain').click() - cy.contains(loginText.titleInitial).should('be.visible') + + cy.findByRole('dialog', { name: loginText.titleInitial }).within(() => { + cy.contains(loginText.actionWaiting).should('be.visible') + }) }) it('cancel button correctly clears error state', () => { - cy.withCtx((ctx) => { - ctx.coreData.authState = { - name: 'AUTH_ERROR_DURING_LOGIN', - message: 'An unexpected error occurred', - browserOpened: false, - } + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_ERROR_DURING_LOGIN', + message: 'An unexpected error occurred', + browserOpened: false, + }) + + throw new Error() + }) }) cy.findByTestId('app-header-bar').within(() => { @@ -517,26 +546,36 @@ describe('App Top Nav Workflows', () => { cy.findByRole('button', { name: 'Log In' }).as('loginButton').click() }) - cy.contains(loginText.titleFailed).should('be.visible') - cy.contains(loginText.bodyError).should('be.visible') - cy.contains('An unexpected error occurred').should('be.visible') + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('button', { name: 'Log In' }).click() + + cy.contains(loginText.titleFailed).should('be.visible') + cy.contains(loginText.bodyError).should('be.visible') + cy.contains('An unexpected error occurred').should('be.visible') + }) cy.percySnapshot() - cy.contains('button', loginText.actionTryAgain).should('be.visible') - cy.contains('button', loginText.actionCancel).click() + cy.findByRole('dialog', { name: loginText.titleFailed }).within(() => { + cy.contains('button', loginText.actionTryAgain).should('be.visible') + cy.contains('button', loginText.actionCancel).click() + }) cy.get('@loginButton').click() cy.contains(loginText.titleInitial).should('be.visible') }) it('closing modal correctly clears error state', () => { - cy.withCtx((ctx) => { - ctx.coreData.authState = { - name: 'AUTH_ERROR_DURING_LOGIN', - message: 'An unexpected error occurred', - browserOpened: false, - } + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_ERROR_DURING_LOGIN', + message: 'An unexpected error occurred', + browserOpened: false, + }) + + throw new Error() + }) }) cy.findByTestId('app-header-bar').within(() => { @@ -544,11 +583,14 @@ describe('App Top Nav Workflows', () => { cy.findByRole('button', { name: 'Log In' }).as('loginButton').click() }) - cy.contains(loginText.titleFailed).should('be.visible') - cy.contains(loginText.bodyError).should('be.visible') - cy.contains('An unexpected error occurred').should('be.visible') + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('button', { name: 'Log In' }).click() + cy.contains(loginText.titleFailed).should('be.visible') + cy.contains(loginText.bodyError).should('be.visible') + cy.contains('An unexpected error occurred').should('be.visible') - cy.findByLabelText(defaultMessages.actions.close).click() + cy.findByLabelText(defaultMessages.actions.close).click() + }) cy.get('@loginButton').click() cy.contains(loginText.titleInitial).should('be.visible') diff --git a/packages/data-context/src/actions/AuthActions.ts b/packages/data-context/src/actions/AuthActions.ts index 5baab4c288..59f1b7f4a2 100644 --- a/packages/data-context/src/actions/AuthActions.ts +++ b/packages/data-context/src/actions/AuthActions.ts @@ -5,7 +5,7 @@ export interface AuthApiShape { getUser(): Promise> logIn(onMessage: (message: AuthStateShape) => void): Promise logOut(): Promise - resetAuthState(): Promise + resetAuthState(): void } export class AuthActions { @@ -44,19 +44,62 @@ export class AuthActions { } async login () { - this.setAuthenticatedUser(await this.authApi.logIn((authState) => { + const loginPromise = new Promise((resolve, reject) => { + // A resolver is exposed to the instance so that we can + // resolve this promise and the original mutation promise + // if a reset occurs this.ctx.update((coreData) => { - coreData.authState = authState + coreData.cancelActiveLogin = () => resolve(null) }) - })) - this.ctx.emitter.authChange() + this.authApi.logIn((authState) => { + this.ctx.update((coreData) => { + coreData.authState = authState + }) + + // Ensure auth state changes during the login lifecycle + // are propagated to the clients + this.ctx.emitter.authChange() + }).then(resolve, reject) + }) + + const user = await loginPromise + + if (!user) { + // if the user is null, this promise is resolving due to a + // login mutation cancellation. the state should already + // be reset, so abort early. + return + } + + this.setAuthenticatedUser(user as AuthenticatedUserShape) + + this.ctx.update((coreData) => { + coreData.cancelActiveLogin = null + }) + + this.resetAuthState() } resetAuthState () { + // closes the express server opened during login, if it's still open + this.authApi.resetAuthState() + + // if a login mutation is still in progress, we + // forcefully resolve it so that the mutation does not persist + if (this.ctx.coreData.cancelActiveLogin) { + this.ctx.coreData.cancelActiveLogin() + + this.ctx.update((coreData) => { + coreData.cancelActiveLogin = null + }) + } + this.ctx.update((coreData) => { coreData.authState = { browserOpened: false } }) + + this.ctx.emitter.authChange() } async logout () { @@ -76,7 +119,9 @@ export class AuthActions { } private setAuthenticatedUser (authUser: AuthenticatedUserShape | null) { - this.ctx.coreData.user = authUser + this.ctx.update((coreData) => { + coreData.user = authUser + }) return this } diff --git a/packages/data-context/src/data/coreDataShape.ts b/packages/data-context/src/data/coreDataShape.ts index 6afc950c7d..0264d2e8c5 100644 --- a/packages/data-context/src/data/coreDataShape.ts +++ b/packages/data-context/src/data/coreDataShape.ts @@ -138,6 +138,7 @@ export interface CoreDataShape { warnings: ErrorWrapperSource[] packageManager: typeof PACKAGE_MANAGERS[number] forceReconfigureProject: ForceReconfigureProjectDataShape | null + cancelActiveLogin: (() => void) | null } /** @@ -209,5 +210,6 @@ export function makeCoreData (modeOptions: Partial = {}): CoreDa scaffoldedFiles: null, packageManager: 'npm', forceReconfigureProject: null, + cancelActiveLogin: null, } } diff --git a/packages/frontend-shared/src/gql-components/Auth.vue b/packages/frontend-shared/src/gql-components/Auth.vue index 14050c9461..0f858b1a8c 100644 --- a/packages/frontend-shared/src/gql-components/Auth.vue +++ b/packages/frontend-shared/src/gql-components/Auth.vue @@ -34,37 +34,45 @@
+